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 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, 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.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('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' ] as const; const debouncedSaveIds = [ 'partMinutes', 'metadataCacheMinutes', 'vodFilenameTemplate', 'partsFilenameTemplate', 'defaultClipFilenameTemplate' ] 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 }); } 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 }); }