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 // History section state let historyDays = parseInt(localStorage.getItem('sofarr-history-days'), 10) || 7; let historyRefreshHandle = null; const HISTORY_REFRESH_MS = 5 * 60 * 1000; // auto-refresh history every 5 min // 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(); initHistoryControls(); 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(); // Reload history with updated showAll param loadHistory(); } 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(); stopHistoryRefresh(); if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; } await fetch('/api/auth/logout', { method: 'POST', headers: csrfToken ? { 'X-CSRF-Token': csrfToken } : {} }); currentUser = null; csrfToken = null; downloads = []; clearHistory(); 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 || '-'; // Always start with status panel hidden (guards against stale display value on re-login) const sp = document.getElementById('status-panel'); sp.style.display = 'none'; sp.innerHTML = ''; document.getElementById('admin-controls').style.display = isAdmin ? 'flex' : 'none'; // Initialise days input from saved value, then load history const daysInput = document.getElementById('history-days'); if (daysInput) daysInput.value = historyDays; loadHistory(); startHistoryRefresh(); } 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 availabilityItem = card.querySelector('.detail-item[data-label="Availability"]'); if (availabilityItem && download.availability !== undefined) { availabilityItem.querySelector('.detail-value').textContent = `${download.availability}%`; availabilityItem.classList.toggle('availability-warning', parseFloat(download.availability) < 100); } } } 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}%`); if (parseFloat(download.availability) < 100) availability.classList.add('availability-warning'); 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 = `| Key | Items | Size | TTL |
|---|---|---|---|
${escapeHtml(e.key)} | ${items} | ${sizeStr} | ${ttlStr} |