Twitch-VOD-Manager/src/renderer-settings.ts
xRangerDE bdf6bac602 feat: window title syncs with active tab / streamer
document.title was stamped once during app boot with the static
"Twitch VOD Manager vX.Y.Z" string. After that, the H1 page-title
in the header updated as the user navigated tabs and selected
streamers, but the OS-level window title — the string shown in the
taskbar, Alt+Tab switcher, and OS notifications — never changed.

Multitasking suffered: a user with three Electron windows pinned
to taskbar all read identical "Twitch VOD Manager v4.6.x", with
no clue which window had what tab or streamer loaded.

Added a setPageTitle(text) helper in renderer.ts that:
- Updates the H1 #pageTitle textContent (the visible header)
- Updates document.title with `${text} - ${appName} v${version}`
  for non-default text, or just `${appName} v${version}` for the
  default app-name fallback
- Exposed on window so the renderer-streamers.ts and
  renderer-settings.ts modules can reach it without crossing the
  module-vs-bundle boundary

Three call sites updated to use the helper:
- showTab → uses for tab-derived titles
- selectStreamer → uses for "xrohat" style streamer titles
- the renderer-settings language-switch refresh path

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 05:29:04 +02:00

978 lines
41 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.className = 'template-lint ok';
lintNode.textContent = UI_TEXT.static.templateLintOk;
return true;
}
lintNode.className = 'template-lint warn';
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);
void refreshAutomationStatusLine();
}, 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) {
const setTitle = (window as unknown as { setPageTitle?: (text: string) => void }).setPageTitle;
if (typeof setTitle === 'function') setTitle(currentStreamer);
else byId('pageTitle').textContent = currentStreamer;
} else {
const setTitle = (window as unknown as { setPageTitle?: (text: string) => void }).setPageTitle;
const text = (UI_TEXT.tabs as Record<string, string>)[activeTab] || UI_TEXT.appName;
if (typeof setTitle === 'function') setTitle(text);
else byId('pageTitle').textContent = text;
}
void refreshRuntimeMetrics();
void refreshAutomationStatusLine();
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 runCleanupDryRun(): Promise<void> {
await runCleanupOnce(true);
}
async function runCleanupNow(): Promise<void> {
await runCleanupOnce(false);
}
async function runCleanupOnce(dryRun: boolean): Promise<void> {
const reportEl = byId('cleanupReport');
const dryBtn = byId<HTMLButtonElement>('btnCleanupDryRun');
const runBtn = byId<HTMLButtonElement>('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<void> {
const summary = byId('storageSummary');
const list = byId('storageList');
const btn = byId<HTMLButtonElement>('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.className = 'storage-stats-table';
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;
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<string | HTMLElement> = [
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);
tr.appendChild(td);
}
const openCell = document.createElement('td');
const openBtn = document.createElement('button');
openBtn.textContent = UI_TEXT.static.storageOpen;
openBtn.className = 'btn-pill';
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.className = 'storage-stats-section';
list.appendChild(heading);
list.appendChild(buildTable(stats.extras));
}
}
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,
download_chat_replay: byId<HTMLInputElement>('downloadChatReplayToggle').checked,
capture_live_chat: byId<HTMLInputElement>('captureLiveChatToggle').checked,
log_stream_events: byId<HTMLInputElement>('logStreamEventsToggle').checked,
auto_resume_live_recording: byId<HTMLInputElement>('autoResumeLiveRecordingToggle').checked,
auto_merge_resumed_parts: byId<HTMLInputElement>('autoMergeResumedPartsToggle').checked,
delete_parts_after_merge: byId<HTMLInputElement>('deletePartsAfterMergeToggle').checked,
discord_webhook_url: byId<HTMLInputElement>('discordWebhookUrl').value.trim(),
discord_notify_live_start: byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked,
discord_notify_live_end: byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked,
discord_notify_vod_complete: byId<HTMLInputElement>('discordNotifyVodCompleteToggle').checked,
discord_notify_vod_auto_queued: byId<HTMLInputElement>('discordNotifyVodAutoQueuedToggle').checked,
auto_vod_download_poll_minutes: parseInt(byId<HTMLInputElement>('autoVodPollMinutes').value, 10) || 15,
auto_vod_max_age_hours: parseInt(byId<HTMLInputElement>('autoVodMaxAgeHours').value, 10) || 24,
auto_cleanup_enabled: byId<HTMLInputElement>('autoCleanupEnabledToggle').checked,
auto_cleanup_days: parseInt(byId<HTMLInputElement>('autoCleanupDays').value, 10) || 30,
auto_cleanup_target: byId<HTMLSelectElement>('autoCleanupTarget').value === 'all' ? 'all' : 'live_only',
auto_cleanup_action: byId<HTMLSelectElement>('autoCleanupAction').value === 'delete' ? 'delete' : 'archive',
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.download_chat_replay === true,
effective.capture_live_chat === true,
effective.log_stream_events !== false,
effective.auto_resume_live_recording !== false,
effective.auto_merge_resumed_parts === true,
effective.delete_parts_after_merge === true,
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<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<HTMLInputElement>('downloadChatReplayToggle').checked = (config.download_chat_replay as boolean) === true;
byId<HTMLInputElement>('captureLiveChatToggle').checked = (config.capture_live_chat as boolean) === true;
byId<HTMLInputElement>('logStreamEventsToggle').checked = (config.log_stream_events as boolean) !== false;
byId<HTMLInputElement>('autoResumeLiveRecordingToggle').checked = (config.auto_resume_live_recording as boolean) !== false;
byId<HTMLInputElement>('autoMergeResumedPartsToggle').checked = (config.auto_merge_resumed_parts as boolean) === true;
byId<HTMLInputElement>('deletePartsAfterMergeToggle').checked = (config.delete_parts_after_merge as boolean) === true;
byId<HTMLInputElement>('discordWebhookUrl').value = (config.discord_webhook_url as string) || '';
byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked = (config.discord_notify_live_start as boolean) === true;
byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked = (config.discord_notify_live_end as boolean) === true;
byId<HTMLInputElement>('discordNotifyVodCompleteToggle').checked = (config.discord_notify_vod_complete as boolean) === true;
byId<HTMLInputElement>('discordNotifyVodAutoQueuedToggle').checked = (config.discord_notify_vod_auto_queued as boolean) === true;
byId<HTMLInputElement>('autoVodPollMinutes').value = String((config.auto_vod_download_poll_minutes as number) || 15);
byId<HTMLInputElement>('autoVodMaxAgeHours').value = String((config.auto_vod_max_age_hours as number) || 24);
byId<HTMLInputElement>('autoCleanupEnabledToggle').checked = (config.auto_cleanup_enabled as boolean) === true;
byId<HTMLInputElement>('autoCleanupDays').value = String((config.auto_cleanup_days as number) || 30);
byId<HTMLSelectElement>('autoCleanupTarget').value = (config.auto_cleanup_target as string) === 'all' ? 'all' : 'live_only';
byId<HTMLSelectElement>('autoCleanupAction').value = (config.auto_cleanup_action as string) === 'delete' ? 'delete' : 'archive';
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',
'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<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 });
}
function formatRelativeTime(ms: number, future: boolean): string {
if (!Number.isFinite(ms) || ms <= 0) {
return future ? UI_TEXT.streamers.autoVodScanEmpty || '' : '-';
}
const seconds = Math.max(0, Math.floor(ms / 1000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
return `${hours}h ${minutes % 60}m`;
}
async function refreshAutomationStatusLine(): Promise<void> {
const lineEl = document.getElementById('autoVodStatusLine');
if (!lineEl) return;
try {
const status = await window.api.getAutomationStatus();
const now = Date.now();
const parts: string[] = [];
if (status.autoVod.watching > 0) {
const lastAgo = status.autoVod.lastRunAt > 0 ? formatRelativeTime(now - status.autoVod.lastRunAt, false) : '-';
const nextIn = status.autoVod.nextRunAt > now ? formatRelativeTime(status.autoVod.nextRunAt - now, true) : '-';
parts.push(`VOD: ${status.autoVod.watching} watched · last ${lastAgo} ago · next in ${nextIn} · last run +${status.autoVod.lastQueuedCount}`);
}
if (status.autoRecord.watching > 0) {
const lastAgo = status.autoRecord.lastRunAt > 0 ? formatRelativeTime(now - status.autoRecord.lastRunAt, false) : '-';
const nextIn = status.autoRecord.nextRunAt > now ? formatRelativeTime(status.autoRecord.nextRunAt - now, true) : '-';
parts.push(`REC: ${status.autoRecord.watching} watched · last ${lastAgo} ago · next in ${nextIn}`);
}
if (parts.length === 0) parts.push('No streamers watched.');
lineEl.textContent = parts.join(' · ');
} catch (_) {
lineEl.textContent = '';
}
}
async function triggerManualAutoVodScan(): Promise<void> {
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
const btn = document.getElementById('btnAutoVodScanNow') as HTMLButtonElement | null;
if (btn) btn.disabled = true;
try {
const result = await window.api.triggerAutoVodScan();
if (toast) {
const tmpl = result.queuedCount > 0
? UI_TEXT.streamers.autoVodScanQueued
: UI_TEXT.streamers.autoVodScanEmpty;
toast((tmpl || '').replace('{count}', String(result.queuedCount)), 'info');
}
} finally {
if (btn) btn.disabled = false;
void refreshAutomationStatusLine();
}
}
async function triggerManualAutoRecordScan(): Promise<void> {
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
const btn = document.getElementById('btnAutoRecordScanNow') as HTMLButtonElement | null;
if (btn) btn.disabled = true;
try {
const result = await window.api.triggerAutoRecordScan();
if (toast) {
const tmpl = result.triggered > 0
? UI_TEXT.streamers.autoRecordScanTriggered
: UI_TEXT.streamers.autoRecordScanEmpty;
toast((tmpl || '').replace('{count}', String(result.triggered)), 'info');
}
} finally {
if (btn) btn.disabled = false;
void refreshAutomationStatusLine();
}
}
(window as unknown as { triggerManualAutoVodScan: typeof triggerManualAutoVodScan }).triggerManualAutoVodScan = triggerManualAutoVodScan;
(window as unknown as { triggerManualAutoRecordScan: typeof triggerManualAutoRecordScan }).triggerManualAutoRecordScan = triggerManualAutoRecordScan;