Twitch-VOD-Manager/src/renderer-settings.ts
xRangerDE 3f04b42b02 feat: auto-resume queue toggle + already-downloaded VOD indicator
Two real UX wins.

1. Auto-resume queue on startup. New checkbox in Settings -> Download
   ("Queue beim Start automatisch fortsetzen"). When enabled and the
   persisted queue has pending items, processQueue() fires ~5 seconds
   after did-finish-load — long enough for the user to see the queue
   and pause if they did not actually want this. Default off so the
   existing behaviour (explicit Start click) is preserved on upgrade.
   The Settings auto-save fingerprint includes the new flag and
   syncSettingsFormFromConfig restores it. Tooltip explains the
   timing on hover.

2. Already-downloaded indicator on VOD cards. Config gains
   downloaded_vod_ids: string[] (bounded to 4096 latest entries).
   Every successful queue-item download appends its parsed VOD ID
   (or every component ID for merge groups). On the VOD grid each
   card whose vod.id is in the set gets a small green checkmark
   badge in the top-right plus a slightly dimmed thumbnail, with a
   localized "Already downloaded" / "Bereits heruntergeladen"
   tooltip. The lookup builds a Set once per render so it stays
   O(1) per card. The renderer refreshes its local config copy on
   every "newly completed" queue update so the badge appears live
   without waiting for a settings save.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:16:21 +02:00

608 lines
22 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 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,
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.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>('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'
] 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 });
}
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 });
}