- Add includeEpisode:true to Sonarr queue and history API requests in both the poller and historyFetcher - Add extractEpisode() / gatherEpisodes() helpers in dashboard.js and history.js to build a sorted, deduplicated episodes array covering all records matching a download title (handles multi- episode packs and series packs) - Replace episodeInfo: sonarrMatch with episodes: gatherEpisodes() across all 8 assignment sites in dashboard.js - Add episodes field to /api/history/recent response items - Frontend: formatEpisodeInfo() renders S01E05 for single episodes or 'Multiple episodes' with hover tooltip listing all for packs - CSS: .episode-info and .multi-episode tooltip styles - ARCHITECTURE.md: update polling table and download/history schemas
1048 lines
36 KiB
JavaScript
1048 lines
36 KiB
JavaScript
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();
|
|
initTabs();
|
|
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);
|
|
});
|
|
}
|
|
|
|
function initTabs() {
|
|
const savedTab = localStorage.getItem('sofarr-active-tab') || 'downloads';
|
|
activateTab(savedTab, false);
|
|
|
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const tab = btn.dataset.tab;
|
|
activateTab(tab, true);
|
|
});
|
|
});
|
|
}
|
|
|
|
function activateTab(tabName, save) {
|
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
btn.classList.toggle('active', btn.dataset.tab === tabName);
|
|
});
|
|
document.querySelectorAll('.tab-panel').forEach(panel => {
|
|
panel.style.display = panel.id === `tab-${tabName}` ? '' : 'none';
|
|
});
|
|
if (save) localStorage.setItem('sofarr-active-tab', tabName);
|
|
// Load history the first time the history tab is shown
|
|
if (tabName === 'history') loadHistory();
|
|
}
|
|
|
|
// --- 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
|
|
const daysInput = document.getElementById('history-days');
|
|
if (daysInput) daysInput.value = historyDays;
|
|
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';
|
|
}
|
|
|
|
// Build an episode-info element for series downloads/history.
|
|
// Single episode: "S01E05 — Episode Title"
|
|
// Multiple episodes: "Multiple episodes" with tooltip listing them all.
|
|
// Returns null if no episode data.
|
|
function formatEpisodeInfo(episodes) {
|
|
if (!episodes || episodes.length === 0) return null;
|
|
const el = document.createElement('p');
|
|
el.className = 'episode-info';
|
|
if (episodes.length === 1) {
|
|
const ep = episodes[0];
|
|
const code = 'S' + String(ep.season).padStart(2, '0') + 'E' + String(ep.episode).padStart(2, '0');
|
|
el.textContent = ep.title ? code + ' \u2014 ' + ep.title : code;
|
|
} else {
|
|
el.textContent = 'Multiple episodes';
|
|
el.classList.add('multi-episode');
|
|
const lines = episodes.map(ep => {
|
|
const code = 'S' + String(ep.season).padStart(2, '0') + 'E' + String(ep.episode).padStart(2, '0');
|
|
return ep.title ? code + ' \u2014 ' + ep.title : code;
|
|
});
|
|
el.setAttribute('data-tooltip', lines.join('\n'));
|
|
}
|
|
return el;
|
|
}
|
|
|
|
// 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: <a href="' + escapeHtml(download.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(download.seriesName) + '</a>';
|
|
} else {
|
|
series.textContent = `Series: ${download.seriesName}`;
|
|
}
|
|
infoDiv.appendChild(series);
|
|
const epEl = formatEpisodeInfo(download.episodes);
|
|
if (epEl) infoDiv.appendChild(epEl);
|
|
}
|
|
|
|
if (download.movieName) {
|
|
const movie = document.createElement('p');
|
|
movie.className = 'download-movie';
|
|
if (isAdmin && download.arrLink) {
|
|
movie.innerHTML = 'Movie: <a href="' + escapeHtml(download.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(download.movieName) + '</a>';
|
|
} 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 = '<span class="path-label">Download:</span> <span class="path-value">' + escapeHtml(download.downloadPath) + '</span>';
|
|
pathsDiv.appendChild(dlPath);
|
|
}
|
|
if (download.targetPath) {
|
|
const tgtPath = document.createElement('div');
|
|
tgtPath.className = 'path-item';
|
|
tgtPath.innerHTML = '<span class="path-label">Target:</span> <span class="path-value">' + escapeHtml(download.targetPath) + '</span>';
|
|
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 = '<p class="status-error">Failed to load status.</p>';
|
|
}
|
|
}
|
|
}
|
|
|
|
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 = `
|
|
<div class="status-header">
|
|
<h3>Server Status</h3>
|
|
<button class="status-close" onclick="closeStatusPanel()">×</button>
|
|
</div>
|
|
<div class="status-grid">
|
|
<div class="status-card">
|
|
<div class="status-card-title">Server</div>
|
|
<div class="status-row"><span>Uptime</span><span>${uptime}</span></div>
|
|
<div class="status-row"><span>Node</span><span>${escapeHtml(s.nodeVersion)}</span></div>
|
|
<div class="status-row"><span>Memory (RSS)</span><span>${s.memoryUsageMB} MB</span></div>
|
|
<div class="status-row"><span>Heap</span><span>${s.heapUsedMB} / ${s.heapTotalMB} MB</span></div>
|
|
</div>
|
|
<div class="status-card">
|
|
<div class="status-card-title">Data Refresh</div>`;
|
|
|
|
const pollIntervalMs = data.polling.intervalMs;
|
|
const clients = data.clients || [];
|
|
const sseClients = clients.filter(c => c.type === 'sse');
|
|
|
|
if (data.polling.enabled) {
|
|
html += `<div class="status-row"><span>Background poll</span><span>${pollIntervalMs / 1000}s</span></div>`;
|
|
} else {
|
|
html += `<div class="status-row"><span>Background poll</span><span>Disabled</span></div>`;
|
|
}
|
|
|
|
const mode = sseClients.length > 0
|
|
? `<span class="status-fg-badge">SSE push</span>`
|
|
: (data.polling.enabled ? 'Background' : 'On-demand (idle)');
|
|
html += `<div class="status-row"><span>Delivery mode</span><span>${mode}</span></div>`;
|
|
|
|
html += `<div class="status-row"><span>SSE clients</span><span>${sseClients.length}</span></div>`;
|
|
for (const c of sseClients) {
|
|
const age = Math.round((Date.now() - c.connectedAt) / 1000);
|
|
html += `<div class="status-row status-row-sub"><span>${escapeHtml(c.user)}</span><span>connected ${age}s ago</span></div>`;
|
|
}
|
|
|
|
html += `</div>`;
|
|
|
|
// Poll timings card
|
|
const lp = data.polling.lastPoll;
|
|
if (lp) {
|
|
const pollAge = Math.round((Date.now() - new Date(lp.timestamp).getTime()) / 1000);
|
|
html += `
|
|
<div class="status-card status-card-wide">
|
|
<div class="status-card-title">Last Poll (${lp.totalMs}ms total, ${pollAge}s ago)</div>
|
|
<div class="status-timings">`;
|
|
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 += `
|
|
<div class="timing-row">
|
|
<span class="timing-label">${escapeHtml(t.label)}</span>
|
|
<div class="timing-bar-bg"><div class="timing-bar" data-w="${barWidth.toFixed(1)}"></div></div>
|
|
<span class="timing-value">${t.ms}ms</span>
|
|
</div>`;
|
|
}
|
|
html += `</div></div>`;
|
|
}
|
|
|
|
// Cache table
|
|
html += `
|
|
<div class="status-card status-card-wide">
|
|
<div class="status-card-title">Cache (${data.cache.entryCount} entries, ${totalKB} KB)</div>
|
|
<table class="status-table">
|
|
<thead><tr><th>Key</th><th>Items</th><th>Size</th><th>TTL</th></tr></thead>
|
|
<tbody>`;
|
|
|
|
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 ? '<span class="status-expired">expired</span>' : (e.ttlRemainingMs / 1000).toFixed(0) + 's';
|
|
const items = e.itemCount !== null ? e.itemCount : '—';
|
|
html += `<tr><td><code>${escapeHtml(e.key)}</code></td><td>${items}</td><td>${sizeStr}</td><td>${ttlStr}</td></tr>`;
|
|
}
|
|
|
|
html += `</tbody></table></div></div>`;
|
|
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';
|
|
}
|
|
|
|
// =============================================================================
|
|
// History section
|
|
// =============================================================================
|
|
|
|
function initHistoryControls() {
|
|
const daysInput = document.getElementById('history-days');
|
|
const refreshBtn = document.getElementById('history-refresh-btn');
|
|
if (daysInput) {
|
|
daysInput.addEventListener('change', () => {
|
|
const v = parseInt(daysInput.value, 10);
|
|
if (v > 0 && v <= 90) {
|
|
historyDays = v;
|
|
localStorage.setItem('sofarr-history-days', v);
|
|
loadHistory();
|
|
}
|
|
});
|
|
}
|
|
if (refreshBtn) {
|
|
refreshBtn.addEventListener('click', () => loadHistory(true));
|
|
}
|
|
}
|
|
|
|
function startHistoryRefresh() {
|
|
stopHistoryRefresh();
|
|
historyRefreshHandle = setInterval(() => loadHistory(), HISTORY_REFRESH_MS);
|
|
}
|
|
|
|
function stopHistoryRefresh() {
|
|
if (historyRefreshHandle) {
|
|
clearInterval(historyRefreshHandle);
|
|
historyRefreshHandle = null;
|
|
}
|
|
}
|
|
|
|
function clearHistory() {
|
|
document.getElementById('history-list').innerHTML = '';
|
|
document.getElementById('no-history').style.display = 'none';
|
|
document.getElementById('history-error').style.display = 'none';
|
|
}
|
|
|
|
async function loadHistory(forceRefresh = false) {
|
|
const listEl = document.getElementById('history-list');
|
|
const loadingEl = document.getElementById('history-loading');
|
|
const errorEl = document.getElementById('history-error');
|
|
const noHistoryEl = document.getElementById('no-history');
|
|
|
|
loadingEl.style.display = 'block';
|
|
errorEl.style.display = 'none';
|
|
noHistoryEl.style.display = 'none';
|
|
|
|
try {
|
|
const params = new URLSearchParams({ days: historyDays });
|
|
if (showAll) params.set('showAll', 'true');
|
|
if (forceRefresh) params.set('_t', Date.now());
|
|
const res = await fetch(`/api/history/recent?${params}`);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const data = await res.json();
|
|
loadingEl.style.display = 'none';
|
|
renderHistory(data.history || []);
|
|
} catch (err) {
|
|
loadingEl.style.display = 'none';
|
|
errorEl.textContent = 'Failed to load history.';
|
|
errorEl.style.display = 'block';
|
|
console.error('[History] Load error:', err);
|
|
}
|
|
}
|
|
|
|
function renderHistory(items) {
|
|
const listEl = document.getElementById('history-list');
|
|
const noHistoryEl = document.getElementById('no-history');
|
|
listEl.innerHTML = '';
|
|
if (!items.length) {
|
|
noHistoryEl.style.display = 'block';
|
|
return;
|
|
}
|
|
noHistoryEl.style.display = 'none';
|
|
items.forEach(item => listEl.appendChild(createHistoryCard(item)));
|
|
}
|
|
|
|
function createHistoryCard(item) {
|
|
const card = document.createElement('div');
|
|
card.className = `history-card ${item.type} ${item.outcome}`;
|
|
|
|
if (item.coverArt) {
|
|
const coverDiv = document.createElement('div');
|
|
coverDiv.className = 'history-cover';
|
|
const img = document.createElement('img');
|
|
img.src = '/api/dashboard/cover-art?url=' + encodeURIComponent(item.coverArt);
|
|
img.alt = item.movieName || item.seriesName || item.title;
|
|
img.loading = 'lazy';
|
|
coverDiv.appendChild(img);
|
|
card.appendChild(coverDiv);
|
|
}
|
|
|
|
const info = document.createElement('div');
|
|
info.className = 'history-info';
|
|
|
|
// Header row: type badge + outcome badge
|
|
const header = document.createElement('div');
|
|
header.className = 'history-card-header';
|
|
|
|
const typeBadge = document.createElement('span');
|
|
typeBadge.className = `history-type-badge ${item.type}`;
|
|
typeBadge.textContent = item.type === 'series' ? '📺 Series' : '🎬 Movie';
|
|
header.appendChild(typeBadge);
|
|
|
|
const outcomeBadge = document.createElement('span');
|
|
outcomeBadge.className = `history-outcome-badge ${item.outcome}`;
|
|
outcomeBadge.textContent = item.outcome === 'imported' ? '✓ Imported' : '✗ Failed';
|
|
header.appendChild(outcomeBadge);
|
|
|
|
if (item.instanceName) {
|
|
const instBadge = document.createElement('span');
|
|
instBadge.className = 'history-instance-badge';
|
|
instBadge.textContent = item.instanceName;
|
|
header.appendChild(instBadge);
|
|
}
|
|
|
|
if (showAll && item.tagBadges && item.tagBadges.length > 0) {
|
|
const unmatched = item.tagBadges.filter(b => !b.matchedUser);
|
|
const matched = item.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 (item.matchedUserTag) {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'download-user-badge';
|
|
badge.textContent = item.matchedUserTag;
|
|
header.appendChild(badge);
|
|
}
|
|
|
|
info.appendChild(header);
|
|
|
|
// Title
|
|
const title = document.createElement('h3');
|
|
title.className = 'history-title';
|
|
title.textContent = item.title;
|
|
info.appendChild(title);
|
|
|
|
// Series/movie name with optional arr link
|
|
if (item.seriesName) {
|
|
const p = document.createElement('p');
|
|
p.className = 'history-media-name';
|
|
if (isAdmin && item.arrLink) {
|
|
p.innerHTML = 'Series: <a href="' + escapeHtml(item.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(item.seriesName) + '</a>';
|
|
} else {
|
|
p.textContent = 'Series: ' + item.seriesName;
|
|
}
|
|
info.appendChild(p);
|
|
const epEl = formatEpisodeInfo(item.episodes);
|
|
if (epEl) info.appendChild(epEl);
|
|
}
|
|
if (item.movieName) {
|
|
const p = document.createElement('p');
|
|
p.className = 'history-media-name';
|
|
if (isAdmin && item.arrLink) {
|
|
p.innerHTML = 'Movie: <a href="' + escapeHtml(item.arrLink) + '" target="_blank" class="arr-link">' + escapeHtml(item.movieName) + '</a>';
|
|
} else {
|
|
p.textContent = 'Movie: ' + item.movieName;
|
|
}
|
|
info.appendChild(p);
|
|
}
|
|
|
|
// Detail pills
|
|
const details = document.createElement('div');
|
|
details.className = 'history-details';
|
|
|
|
if (item.completedAt) {
|
|
details.appendChild(createDetailItem('Completed', formatDate(item.completedAt)));
|
|
}
|
|
if (item.quality) {
|
|
details.appendChild(createDetailItem('Quality', item.quality));
|
|
}
|
|
|
|
// Failed imports: show failure message
|
|
if (item.outcome === 'failed' && item.failureMessage) {
|
|
const failItem = document.createElement('div');
|
|
failItem.className = 'history-failure-message';
|
|
failItem.textContent = item.failureMessage;
|
|
details.appendChild(failItem);
|
|
}
|
|
|
|
info.appendChild(details);
|
|
card.appendChild(info);
|
|
return card;
|
|
}
|