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 { 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('vodFilenameTemplate').value.trim(), byId('partsFilenameTemplate').value.trim(), byId('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 = { 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('vodFilenameTemplate').value = selected.vod; byId('partsFilenameTemplate').value = selected.parts; byId('defaultClipFilenameTemplate').value = selected.clip; validateFilenameTemplates(); } async function refreshRuntimeMetrics(showLoading = true): Promise { 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 { 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('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)[activeTab] || UI_TEXT.appName; } void refreshRuntimeMetrics(); validateFilenameTemplates(); } function updateLanguagePicker(lang: string): void { const de = byId('langOptionDe'); const en = byId('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 { const btn = byId(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 runCleanupDryRun(): Promise { await runCleanupOnce(true); } async function runCleanupNow(): Promise { await runCleanupOnce(false); } async function runCleanupOnce(dryRun: boolean): Promise { const reportEl = byId('cleanupReport'); const dryBtn = byId('btnCleanupDryRun'); const runBtn = byId('btnCleanupRunNow'); dryBtn.disabled = true; runBtn.disabled = true; reportEl.textContent = UI_TEXT.static.storageScanning; try { const report = await window.api.runStorageCleanup({ dryRun }); if (report.candidates === 0) { reportEl.textContent = UI_TEXT.static.cleanupReportEmpty.replace('{days}', String(report.cutoffDays)); } else if (dryRun) { reportEl.textContent = UI_TEXT.static.cleanupReportPreview .replace('{count}', String(report.candidates)) .replace('{size}', formatBytesForMetrics(report.bytesFreed)); } else { const failedSuffix = report.failed > 0 ? UI_TEXT.static.cleanupReportFailedSuffix.replace('{failed}', String(report.failed)) : ''; reportEl.textContent = UI_TEXT.static.cleanupReportDone .replace('{count}', String(report.processed)) .replace('{size}', formatBytesForMetrics(report.bytesFreed)) .replace('{failed}', failedSuffix); // Refresh the storage list since files moved/disappeared. void refreshStorageStats(); } } catch (e) { reportEl.textContent = String(e); } finally { dryBtn.disabled = false; runBtn.disabled = false; } } async function refreshStorageStats(): Promise { const summary = byId('storageSummary'); const list = byId('storageList'); const btn = byId('btnRefreshStorage'); const old = btn.textContent || ''; btn.disabled = true; btn.textContent = UI_TEXT.static.storageScanning; summary.textContent = UI_TEXT.static.storageScanning; list.replaceChildren(); try { const stats = await window.api.getStorageStats(); renderStorageStats(stats); } catch { summary.textContent = UI_TEXT.static.storageEmpty; } finally { btn.disabled = false; btn.textContent = old || UI_TEXT.static.storageRefresh; } } function renderStorageStats(stats: StorageStatsResult): void { const summary = byId('storageSummary'); const list = byId('storageList'); if (!stats.rootExists) { summary.textContent = UI_TEXT.static.storageEmpty; list.replaceChildren(); return; } summary.textContent = UI_TEXT.static.storageSummary .replace('{files}', String(stats.totalFiles)) .replace('{size}', formatBytesForMetrics(stats.totalBytes)) .replace('{free}', stats.freeBytes !== null ? formatBytesForMetrics(stats.freeBytes) : '-'); list.replaceChildren(); if (stats.streamers.length === 0 && stats.extras.length === 0) return; const buildTable = (rows: StreamerStorageEntry[]): HTMLTableElement => { const table = document.createElement('table'); table.style.width = '100%'; table.style.borderCollapse = 'collapse'; table.style.fontSize = '12px'; const thead = document.createElement('thead'); const headRow = document.createElement('tr'); const headers = [ UI_TEXT.static.storageColumnFolder, UI_TEXT.static.storageColumnFiles, UI_TEXT.static.storageColumnTotal, UI_TEXT.static.storageColumnLive, UI_TEXT.static.storageColumnChat, '' ]; for (const h of headers) { const th = document.createElement('th'); th.textContent = h; th.style.textAlign = 'left'; th.style.padding = '4px 8px'; th.style.color = 'var(--text-secondary)'; th.style.borderBottom = '1px solid var(--border-soft)'; th.style.fontWeight = '500'; headRow.appendChild(th); } thead.appendChild(headRow); table.appendChild(thead); const tbody = document.createElement('tbody'); for (const row of rows) { const tr = document.createElement('tr'); const cells: Array = [ row.name, String(row.fileCount), formatBytesForMetrics(row.totalBytes), row.liveBytes > 0 ? formatBytesForMetrics(row.liveBytes) : '-', row.chatBytes > 0 ? formatBytesForMetrics(row.chatBytes) : '-' ]; for (const c of cells) { const td = document.createElement('td'); if (typeof c === 'string') td.textContent = c; else td.appendChild(c); td.style.padding = '4px 8px'; td.style.borderBottom = '1px solid var(--border-soft)'; tr.appendChild(td); } const openCell = document.createElement('td'); openCell.style.padding = '4px 8px'; openCell.style.borderBottom = '1px solid var(--border-soft)'; const openBtn = document.createElement('button'); openBtn.textContent = UI_TEXT.static.storageOpen; openBtn.className = 'btn-secondary'; openBtn.style.fontSize = '11px'; openBtn.style.padding = '2px 8px'; openBtn.addEventListener('click', () => { void window.api.openFolder(row.folderPath); }); openCell.appendChild(openBtn); tr.appendChild(openCell); tbody.appendChild(tr); } table.appendChild(tbody); return table; }; if (stats.streamers.length > 0) { list.appendChild(buildTable(stats.streamers)); } if (stats.extras.length > 0) { const heading = document.createElement('div'); heading.textContent = UI_TEXT.static.storageOtherFolders; heading.style.color = 'var(--text-secondary)'; heading.style.fontSize = '12px'; heading.style.margin = '12px 0 4px'; list.appendChild(heading); list.appendChild(buildTable(stats.extras)); } } async function exportConfigToFile(): Promise { 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 { 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 { 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 { 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 { 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 { return { client_id: byId('clientId').value.trim(), client_secret: byId('clientSecret').value.trim() }; } function syncPartMinutesFieldState(): void { const downloadMode = byId('downloadMode').value; const partMinutes = byId('partMinutes'); const label = byId('partMinutesLabel'); const isSplitMode = downloadMode === 'parts'; partMinutes.disabled = !isSplitMode; partMinutes.setAttribute('aria-disabled', String(!isSplitMode)); label.classList.toggle('input-disabled', !isSplitMode); } function collectDownloadSettingsPayload(): Partial { return { download_mode: byId('downloadMode').value as 'parts' | 'full', part_minutes: parseInt(byId('partMinutes').value, 10) || 120, parallel_downloads: parseInt(byId('parallelDownloads').value, 10) || 1, performance_mode: byId('performanceMode').value as 'stability' | 'balanced' | 'speed', smart_queue_scheduler: byId('smartSchedulerToggle').checked, prevent_duplicate_downloads: byId('duplicatePreventionToggle').checked, persist_queue_on_restart: byId('persistQueueToggle').checked, auto_resume_queue_on_startup: byId('autoResumeQueueToggle').checked, notify_on_each_completion: byId('notifyEachCompletionToggle').checked, streamlink_disable_ads: byId('streamlinkDisableAdsToggle').checked, download_chat_replay: byId('downloadChatReplayToggle').checked, capture_live_chat: byId('captureLiveChatToggle').checked, log_stream_events: byId('logStreamEventsToggle').checked, discord_webhook_url: byId('discordWebhookUrl').value.trim(), discord_notify_live_start: byId('discordNotifyLiveStartToggle').checked, discord_notify_live_end: byId('discordNotifyLiveEndToggle').checked, discord_notify_vod_complete: byId('discordNotifyVodCompleteToggle').checked, discord_notify_vod_auto_queued: byId('discordNotifyVodAutoQueuedToggle').checked, auto_vod_download_poll_minutes: parseInt(byId('autoVodPollMinutes').value, 10) || 15, auto_vod_max_age_hours: parseInt(byId('autoVodMaxAgeHours').value, 10) || 24, auto_cleanup_enabled: byId('autoCleanupEnabledToggle').checked, auto_cleanup_days: parseInt(byId('autoCleanupDays').value, 10) || 30, auto_cleanup_target: byId('autoCleanupTarget').value === 'all' ? 'all' : 'live_only', auto_cleanup_action: byId('autoCleanupAction').value === 'delete' ? 'delete' : 'archive', streamlink_quality: byId('streamlinkQuality').value, metadata_cache_minutes: parseInt(byId('metadataCacheMinutes').value, 10) || 10 }; } function collectFilenameTemplatePayload(showAlert = false): Partial | null { if (!validateFilenameTemplates(showAlert)) { return null; } 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.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.download_chat_replay === true, effective.capture_live_chat === true, effective.log_stream_events !== false, effective.discord_webhook_url ?? '', effective.discord_notify_live_start === true, effective.discord_notify_live_end === true, effective.discord_notify_vod_complete === true, effective.discord_notify_vod_auto_queued === true, effective.auto_vod_download_poll_minutes ?? 15, effective.auto_vod_max_age_hours ?? 24, effective.auto_cleanup_enabled === true, effective.auto_cleanup_days ?? 30, effective.auto_cleanup_target ?? 'live_only', effective.auto_cleanup_action ?? 'archive', 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('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('parallelDownloads').value = String((config.parallel_downloads as number) || 1); 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; byId('persistQueueToggle').checked = (config.persist_queue_on_restart as boolean) !== false; byId('autoResumeQueueToggle').checked = (config.auto_resume_queue_on_startup as boolean) === true; byId('notifyEachCompletionToggle').checked = (config.notify_on_each_completion as boolean) === true; byId('streamlinkDisableAdsToggle').checked = (config.streamlink_disable_ads as boolean) !== false; byId('downloadChatReplayToggle').checked = (config.download_chat_replay as boolean) === true; byId('captureLiveChatToggle').checked = (config.capture_live_chat as boolean) === true; byId('logStreamEventsToggle').checked = (config.log_stream_events as boolean) !== false; byId('discordWebhookUrl').value = (config.discord_webhook_url as string) || ''; byId('discordNotifyLiveStartToggle').checked = (config.discord_notify_live_start as boolean) === true; byId('discordNotifyLiveEndToggle').checked = (config.discord_notify_live_end as boolean) === true; byId('discordNotifyVodCompleteToggle').checked = (config.discord_notify_vod_complete as boolean) === true; byId('discordNotifyVodAutoQueuedToggle').checked = (config.discord_notify_vod_auto_queued as boolean) === true; byId('autoVodPollMinutes').value = String((config.auto_vod_download_poll_minutes as number) || 15); byId('autoVodMaxAgeHours').value = String((config.auto_vod_max_age_hours as number) || 24); byId('autoCleanupEnabledToggle').checked = (config.auto_cleanup_enabled as boolean) === true; byId('autoCleanupDays').value = String((config.auto_cleanup_days as number) || 30); byId('autoCleanupTarget').value = (config.auto_cleanup_target as string) === 'all' ? 'all' : 'live_only'; byId('autoCleanupAction').value = (config.auto_cleanup_action as string) === 'delete' ? 'delete' : 'archive'; byId('streamlinkQuality').value = (config.streamlink_quality as string) || 'best'; byId('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10); byId('vodFilenameTemplate').value = (config.filename_template_vod as string) || '{title}.mp4'; 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'; syncPartMinutesFieldState(); validateFilenameTemplates(); lastPersistedSettingsFingerprint = getSettingsFingerprint({}); } 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', 'parallelDownloads', 'performanceMode', 'smartSchedulerToggle', 'duplicatePreventionToggle', 'persistQueueToggle', 'autoResumeQueueToggle', 'notifyEachCompletionToggle', 'streamlinkDisableAdsToggle', 'downloadChatReplayToggle', 'captureLiveChatToggle', 'logStreamEventsToggle', 'discordNotifyLiveStartToggle', 'discordNotifyLiveEndToggle', 'discordNotifyVodCompleteToggle', 'autoCleanupEnabledToggle', 'autoCleanupTarget', 'autoCleanupAction', 'streamlinkQuality' ] as const; const debouncedSaveIds = [ 'partMinutes', 'metadataCacheMinutes', 'vodFilenameTemplate', 'partsFilenameTemplate', 'defaultClipFilenameTemplate', 'discordWebhookUrl', 'autoCleanupDays' ] as const; const credentialIds = [ 'clientId', 'clientSecret' ] as const; const triggerImmediateSave = () => { void flushSettingsAutoSave(false); }; byId('downloadMode').addEventListener('change', syncPartMinutesFieldState); 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 { const folder = await window.api.selectFolder(); if (!folder) { return; } byId('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 }); }