let currentUser = null; let downloads = []; let isAdmin = false; let showAll = false; let csrfToken = null; // double-submit CSRF token, sent as X-CSRF-Token on mutating requests const SPLASH_MIN_MS = 1200; // minimum splash display time // SSE stream state let sseSource = null; let sseReconnectTimer = null; const SSE_RECONNECT_MS = 3000; // browser already retries, but we add explicit backoff too // Apply saved theme immediately (before DOMContentLoaded to avoid flash) (function() { const saved = localStorage.getItem('sofarr-theme') || 'light'; document.documentElement.setAttribute('data-theme', saved); })(); // Check authentication on load document.addEventListener('DOMContentLoaded', () => { checkAuthentication(); initThemeSwitcher(); document.getElementById('login-form').addEventListener('submit', handleLogin); document.getElementById('logout-btn').addEventListener('click', handleLogout); document.getElementById('show-all-toggle').addEventListener('change', handleShowAllToggle); document.getElementById('status-btn').addEventListener('click', toggleStatusPanel); }); function initThemeSwitcher() { const saved = localStorage.getItem('sofarr-theme') || 'light'; document.querySelectorAll('.theme-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.theme === saved); btn.addEventListener('click', () => setTheme(btn.dataset.theme)); }); } function setTheme(theme) { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('sofarr-theme', theme); document.querySelectorAll('.theme-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.theme === theme); }); } // --- SSE connection management --- function startSSE() { stopSSE(); const params = showAll ? '?showAll=true' : ''; const source = new EventSource('/api/dashboard/stream' + params); sseSource = source; let firstMessage = true; source.onmessage = (event) => { try { const data = JSON.parse(event.data); currentUser = data.user; isAdmin = !!data.isAdmin; downloads = data.downloads; document.getElementById('currentUser').textContent = currentUser || '-'; renderDownloads(); hideError(); if (firstMessage) { firstMessage = false; hideLoading(); } } catch (err) { console.error('[SSE] Failed to parse message:', err); } }; source.onerror = () => { // EventSource retries automatically; we just log and show a reconnecting indicator console.warn('[SSE] Connection lost, browser will retry...'); }; console.log('[SSE] Stream connected'); } function stopSSE() { if (sseReconnectTimer) { clearTimeout(sseReconnectTimer); sseReconnectTimer = null; } if (sseSource) { sseSource.close(); sseSource = null; console.log('[SSE] Stream closed'); } } function handleShowAllToggle(e) { showAll = e.target.checked; // Re-open stream with updated showAll param startSSE(); } function fadeOutLogin() { return new Promise(resolve => { const login = document.getElementById('login-container'); login.classList.add('fade-out'); login.addEventListener('transitionend', () => { login.style.display = 'none'; login.classList.remove('fade-out'); resolve(); }, { once: true }); }); } function showSplash() { const splash = document.getElementById('splash-screen'); splash.style.display = 'flex'; splash.style.opacity = '1'; splash.classList.remove('fade-out'); } function dismissSplash(startTime) { return new Promise(resolve => { const elapsed = Date.now() - (startTime || 0); const remaining = Math.max(0, SPLASH_MIN_MS - elapsed); setTimeout(() => { const splash = document.getElementById('splash-screen'); splash.classList.add('fade-out'); // Fallback: resolve after transition duration + buffer in case // transitionend never fires (e.g. display was toggled in same frame) const TRANSITION_MS = 400; const fallback = setTimeout(() => { splash.style.display = 'none'; resolve(); }, TRANSITION_MS + 100); splash.addEventListener('transitionend', () => { clearTimeout(fallback); splash.style.display = 'none'; resolve(); }, { once: true }); }, remaining); }); } async function checkAuthentication() { const splashStart = Date.now(); try { // Fetch both auth state and a fresh CSRF token in parallel const [meRes, csrfRes] = await Promise.all([ fetch('/api/auth/me'), fetch('/api/auth/csrf') ]); const data = await meRes.json(); const csrfData = await csrfRes.json(); if (csrfData.csrfToken) csrfToken = csrfData.csrfToken; if (data.authenticated) { currentUser = data.user; isAdmin = !!data.user.isAdmin; showDashboard(); showLoading(); startSSE(); await dismissSplash(splashStart); } else { await dismissSplash(splashStart); showLogin(); } } catch (err) { console.error('Authentication check failed:', err); await dismissSplash(splashStart); showLogin(); } } async function handleLogin(e) { e.preventDefault(); const username = document.getElementById('username').value; const password = document.getElementById('password').value; const rememberMe = document.getElementById('remember-me').checked; try { const response = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, rememberMe }) }); const data = await response.json(); if (data.success) { currentUser = data.user; isAdmin = !!data.user.isAdmin; // Store CSRF token returned by login for use in subsequent requests if (data.csrfToken) csrfToken = data.csrfToken; // Fade out login, then show splash while opening SSE stream. // requestAnimationFrame ensures the browser paints the splash at // opacity:1 before dismissSplash adds fade-out, so the CSS // transition fires and transitionend is guaranteed. await fadeOutLogin(); showSplash(); await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); showDashboard(); showLoading(); const splashStart = Date.now(); startSSE(); await dismissSplash(splashStart); } else { showLoginError(data.error || 'Login failed'); } } catch (err) { showLoginError('Login failed. Please try again.'); console.error(err); } } async function handleLogout() { try { stopSSE(); await fetch('/api/auth/logout', { method: 'POST', headers: csrfToken ? { 'X-CSRF-Token': csrfToken } : {} }); currentUser = null; csrfToken = null; downloads = []; showLogin(); } catch (err) { console.error('Logout failed:', err); } } function showLogin() { document.getElementById('login-container').style.display = 'flex'; document.getElementById('dashboard-container').style.display = 'none'; hideLoginError(); } function showDashboard() { document.getElementById('login-container').style.display = 'none'; document.getElementById('dashboard-container').style.display = 'block'; document.getElementById('currentUser').textContent = currentUser.name || '-'; document.getElementById('admin-controls').style.display = isAdmin ? 'flex' : 'none'; } function showLoginError(message) { const errorDiv = document.getElementById('login-error'); errorDiv.textContent = message; errorDiv.style.display = 'block'; } function hideLoginError() { const errorDiv = document.getElementById('login-error'); errorDiv.style.display = 'none'; } // fetchUserDownloads is kept for the showAll toggle re-connection case // but the primary data path is now via SSE (startSSE / EventSource). function renderDownloads() { const downloadsList = document.getElementById('downloads-list'); const noDownloads = document.getElementById('no-downloads'); if (downloads.length === 0) { noDownloads.style.display = 'block'; downloadsList.innerHTML = ''; return; } noDownloads.style.display = 'none'; // Get existing cards const existingCards = new Map(); downloadsList.querySelectorAll('.download-card').forEach(card => { existingCards.set(card.dataset.id, card); }); // Track which downloads we've processed const processedIds = new Set(); downloads.forEach(download => { const id = download.title; processedIds.add(id); const existingCard = existingCards.get(id); if (existingCard) { // Update existing card updateDownloadCard(existingCard, download); } else { // Create new card const card = createDownloadCard(download); downloadsList.appendChild(card); } }); // Remove cards for downloads that no longer exist existingCards.forEach((card, id) => { if (!processedIds.has(id)) { card.remove(); } }); } function updateDownloadCard(card, download) { // Update status const statusEl = card.querySelector('.download-status'); if (statusEl && statusEl.textContent !== download.status) { statusEl.textContent = download.status; statusEl.className = `download-status ${download.status}`; } // Update progress bar and missing pieces const progressContainer = card.querySelector('.progress-container'); if (progressContainer && download.progress !== undefined) { const progressBar = progressContainer.querySelector('.progress-bar'); const progressText = progressContainer.querySelector('.progress-text'); const missingText = progressContainer.querySelector('.missing-text'); if (progressBar) { const downloaded = progressBar.querySelector('.downloaded'); if (downloaded) { downloaded.style.width = download.progress + '%'; } } if (progressText) { progressText.textContent = download.progress + '%'; } if (missingText) { const totalMb = parseFloat(download.mb) || parseFloat(download.size); const missingMb = parseFloat(download.mbmissing) || 0; if (missingMb > 0 && totalMb > 0) { missingText.textContent = `(missing ${missingMb.toFixed(1)} of ${totalMb.toFixed(1)} MB)`; } else { missingText.textContent = ''; } } } // Update speed const speedEl = card.querySelector('.detail-item[data-label="Speed"] .detail-value'); if (speedEl && download.speed !== undefined) { speedEl.textContent = download.speed; } // Update ETA const etaEl = card.querySelector('.detail-item[data-label="ETA"] .detail-value'); if (etaEl && download.eta !== undefined) { etaEl.textContent = download.eta; } // Update qBittorrent-specific fields if (download.qbittorrent) { const seedsEl = card.querySelector('.detail-item[data-label="Seeds"] .detail-value'); if (seedsEl && download.seeds !== undefined) { seedsEl.textContent = download.seeds; } const peersEl = card.querySelector('.detail-item[data-label="Peers"] .detail-value'); if (peersEl && download.peers !== undefined) { peersEl.textContent = download.peers; } const availabilityEl = card.querySelector('.detail-item[data-label="Availability"] .detail-value'); if (availabilityEl && download.availability !== undefined) { availabilityEl.textContent = `${download.availability}%`; } } } function createDownloadCard(download) { const card = document.createElement('div'); card.className = `download-card ${download.type}`; card.dataset.id = download.title; // Cover art if (download.coverArt) { const coverDiv = document.createElement('div'); coverDiv.className = 'download-cover'; const coverImg = document.createElement('img'); // Proxy cover art through the server so the CSP img-src 'self' rule // is satisfied (external poster URLs would be blocked otherwise). coverImg.src = download.coverArt ? '/api/dashboard/cover-art?url=' + encodeURIComponent(download.coverArt) : ''; coverImg.alt = download.movieName || download.seriesName || download.title; coverImg.loading = 'lazy'; coverDiv.appendChild(coverImg); card.appendChild(coverDiv); } // Info wrapper const infoDiv = document.createElement('div'); infoDiv.className = 'download-info'; const header = document.createElement('div'); header.className = 'download-header'; const type = document.createElement('span'); type.className = `download-type ${download.type}`; if (download.type === 'series') { type.textContent = '📺 Series'; } else if (download.type === 'movie') { type.textContent = '🎬 Movie'; } else if (download.type === 'torrent') { const instName = download.instanceName ? ` (${download.instanceName})` : ''; type.textContent = `📥 Torrent${instName}`; } else { type.textContent = download.type; } const status = document.createElement('span'); status.className = `download-status ${download.status}`; status.textContent = download.status; header.appendChild(type); header.appendChild(status); if (download.importIssues && download.importIssues.length > 0) { const issueBadge = document.createElement('span'); issueBadge.className = 'import-issue-badge'; issueBadge.textContent = 'Import Pending'; issueBadge.setAttribute('data-tooltip', download.importIssues.join('\n')); header.appendChild(issueBadge); } const title = document.createElement('h3'); title.className = 'download-title'; title.textContent = download.title; infoDiv.appendChild(header); infoDiv.appendChild(title); if (download.seriesName) { const series = document.createElement('p'); series.className = 'download-series'; if (isAdmin && download.arrLink) { series.innerHTML = 'Series: ' + escapeHtml(download.seriesName) + ''; } else { series.textContent = `Series: ${download.seriesName}`; } infoDiv.appendChild(series); } if (download.movieName) { const movie = document.createElement('p'); movie.className = 'download-movie'; if (isAdmin && download.arrLink) { movie.innerHTML = 'Movie: ' + escapeHtml(download.movieName) + ''; } else { movie.textContent = `Movie: ${download.movieName}`; } infoDiv.appendChild(movie); } if (showAll && download.tagBadges && download.tagBadges.length > 0) { // In showAll mode: render all tags classified by whether they match an Emby user. // Unmatched (no known Emby user) → amber, leftmost. // Matched → show Emby display name in accent colour, rightmost. const unmatched = download.tagBadges.filter(b => !b.matchedUser); const matched = download.tagBadges.filter(b => b.matchedUser); for (const b of unmatched) { const badge = document.createElement('span'); badge.className = 'download-user-badge unmatched'; badge.textContent = b.label; header.appendChild(badge); } for (const b of matched) { const badge = document.createElement('span'); badge.className = 'download-user-badge'; badge.textContent = b.matchedUser; header.appendChild(badge); } } else if (download.matchedUserTag) { // Normal (non-showAll) view: show only the current user's matched tag const matchedBadge = document.createElement('span'); matchedBadge.className = 'download-user-badge'; matchedBadge.textContent = download.matchedUserTag; header.appendChild(matchedBadge); } const details = document.createElement('div'); details.className = 'download-details'; const size = createDetailItem('Size', formatSize(download.size)); details.appendChild(size); if (download.progress !== undefined) { const progressItem = document.createElement('div'); progressItem.className = 'detail-item progress-item'; progressItem.dataset.label = 'Progress'; const labelSpan = document.createElement('span'); labelSpan.className = 'detail-label'; labelSpan.textContent = 'Progress'; const valueDiv = document.createElement('div'); valueDiv.className = 'progress-container'; // Progress bar with segments const totalMb = parseFloat(download.mb) || parseFloat(download.size); const missingMb = parseFloat(download.mbmissing) || 0; const downloadedMb = totalMb - missingMb; const progressPercent = parseFloat(download.progress) || 0; const missingPercent = totalMb > 0 ? (missingMb / totalMb) * 100 : 0; const progressBar = document.createElement('div'); progressBar.className = 'progress-bar'; // Downloaded portion (green) if (progressPercent > 0) { const downloaded = document.createElement('div'); downloaded.className = 'progress-segment downloaded'; downloaded.style.width = progressPercent + '%'; progressBar.appendChild(downloaded); } valueDiv.appendChild(progressBar); // Text showing percentage const progressText = document.createElement('span'); progressText.className = 'progress-text'; progressText.textContent = download.progress + '%'; valueDiv.appendChild(progressText); // Missing pieces text if (missingMb > 0 && totalMb > 0) { const missingText = document.createElement('span'); missingText.className = 'missing-text'; missingText.textContent = `(missing ${missingMb.toFixed(1)} of ${totalMb.toFixed(1)} MB)`; valueDiv.appendChild(missingText); } progressItem.appendChild(labelSpan); progressItem.appendChild(valueDiv); details.appendChild(progressItem); } if (download.speed) { const speed = createDetailItem('Speed', download.speed); details.appendChild(speed); } if (download.eta) { const eta = createDetailItem('ETA', download.eta); details.appendChild(eta); } // qBittorrent-specific fields if (download.qbittorrent) { if (download.seeds !== undefined) { const seeds = createDetailItem('Seeds', download.seeds); details.appendChild(seeds); } if (download.peers !== undefined) { const peers = createDetailItem('Peers', download.peers); details.appendChild(peers); } if (download.availability !== undefined) { const availability = createDetailItem('Availability', `${download.availability}%`); details.appendChild(availability); } } if (download.completedAt) { const completed = createDetailItem('Completed', formatDate(download.completedAt)); details.appendChild(completed); } if (isAdmin && (download.downloadPath || download.targetPath)) { const pathsDiv = document.createElement('div'); pathsDiv.className = 'download-paths'; if (download.downloadPath) { const dlPath = document.createElement('div'); dlPath.className = 'path-item'; dlPath.innerHTML = 'Download: ' + escapeHtml(download.downloadPath) + ''; pathsDiv.appendChild(dlPath); } if (download.targetPath) { const tgtPath = document.createElement('div'); tgtPath.className = 'path-item'; tgtPath.innerHTML = 'Target: ' + escapeHtml(download.targetPath) + ''; pathsDiv.appendChild(tgtPath); } details.appendChild(pathsDiv); } infoDiv.appendChild(details); card.appendChild(infoDiv); return card; } function createDetailItem(label, value) { const item = document.createElement('div'); item.className = 'detail-item'; item.dataset.label = label; const labelSpan = document.createElement('span'); labelSpan.className = 'detail-label'; labelSpan.textContent = label; const valueSpan = document.createElement('span'); valueSpan.className = 'detail-value'; valueSpan.textContent = value; item.appendChild(labelSpan); item.appendChild(valueSpan); return item; } function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } let statusRefreshHandle = null; const STATUS_REFRESH_MS = 5000; async function toggleStatusPanel() { const panel = document.getElementById('status-panel'); if (panel.style.display !== 'none') { panel.style.display = 'none'; if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; } return; } panel.style.display = 'block'; await refreshStatusPanel(); if (statusRefreshHandle) clearInterval(statusRefreshHandle); statusRefreshHandle = setInterval(refreshStatusPanel, STATUS_REFRESH_MS); } function closeStatusPanel() { document.getElementById('status-panel').style.display = 'none'; if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; } } async function refreshStatusPanel() { const panel = document.getElementById('status-panel'); if (!panel || panel.style.display === 'none') return; try { const res = await fetch('/api/dashboard/status'); if (!res.ok) throw new Error('Failed to fetch status'); const data = await res.json(); renderStatusPanel(data, panel); } catch (err) { // Don't overwrite panel on transient error during auto-refresh if (!panel.innerHTML || panel.innerHTML.includes('status-loading')) { panel.innerHTML = '

Failed to load status.

'; } } } function renderStatusPanel(data, panel) { const s = data.server; const hrs = Math.floor(s.uptimeSeconds / 3600); const mins = Math.floor((s.uptimeSeconds % 3600) / 60); const secs = s.uptimeSeconds % 60; const uptime = `${hrs}h ${mins}m ${secs}s`; const totalKB = (data.cache.totalSizeBytes / 1024).toFixed(1); let html = `

Server Status

Server
Uptime${uptime}
Node${escapeHtml(s.nodeVersion)}
Memory (RSS)${s.memoryUsageMB} MB
Heap${s.heapUsedMB} / ${s.heapTotalMB} MB
Data Refresh
`; const pollIntervalMs = data.polling.intervalMs; const clients = data.clients || []; const sseClients = clients.filter(c => c.type === 'sse'); if (data.polling.enabled) { html += `
Background poll${pollIntervalMs / 1000}s
`; } else { html += `
Background pollDisabled
`; } const mode = sseClients.length > 0 ? `SSE push` : (data.polling.enabled ? 'Background' : 'On-demand (idle)'); html += `
Delivery mode${mode}
`; html += `
SSE clients${sseClients.length}
`; for (const c of sseClients) { const age = Math.round((Date.now() - c.connectedAt) / 1000); html += `
${escapeHtml(c.user)}connected ${age}s ago
`; } html += `
`; // Poll timings card const lp = data.polling.lastPoll; if (lp) { const pollAge = Math.round((Date.now() - new Date(lp.timestamp).getTime()) / 1000); html += `
Last Poll (${lp.totalMs}ms total, ${pollAge}s ago)
`; const maxTaskMs = lp.tasks.reduce((max, t) => Math.max(max, t.ms), 1); for (const t of lp.tasks) { const barWidth = Math.max(2, (t.ms / maxTaskMs) * 100); html += `
${escapeHtml(t.label)}
${t.ms}ms
`; } html += `
`; } // Cache table html += `
Cache (${data.cache.entryCount} entries, ${totalKB} KB)
`; for (const e of data.cache.entries) { const sizeStr = e.sizeBytes > 1024 ? (e.sizeBytes / 1024).toFixed(1) + ' KB' : e.sizeBytes + ' B'; const ttlStr = e.expired ? 'expired' : (e.ttlRemainingMs / 1000).toFixed(0) + 's'; const items = e.itemCount !== null ? e.itemCount : '—'; html += ``; } html += `
KeyItemsSizeTTL
${escapeHtml(e.key)}${items}${sizeStr}${ttlStr}
`; panel.innerHTML = html; // Set bar widths via JS DOM assignment — immune to CSP style-src restrictions panel.querySelectorAll('.timing-bar[data-w]').forEach(el => { el.style.width = el.dataset.w + '%'; }); } function formatSize(size) { if (!size) return 'N/A'; // If already a formatted string (e.g., "21.5 GB"), return as-is if (typeof size === 'string') { return size; } // If it's a number (bytes), format it const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(size) / Math.log(1024)); return Math.round(size / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; } function formatDate(dateString) { if (!dateString) return 'N/A'; return new Date(dateString).toLocaleString(); } function showError(message) { const errorDiv = document.getElementById('error-message'); errorDiv.textContent = message; errorDiv.style.display = 'block'; } function hideError() { const errorDiv = document.getElementById('error-message'); errorDiv.style.display = 'none'; } function showLoading() { const loading = document.getElementById('loading'); loading.style.display = 'block'; } function hideLoading() { const loading = document.getElementById('loading'); loading.style.display = 'none'; }