// Copyright (c) 2026 Gordon Bolton. MIT License. import { state } from '../state.js'; import { formatSize, formatSpeed, formatEpisodeInfo, escapeHtml } from '../utils/format.js'; import { handleBlocklistSearch } from '../api.js'; export function renderDownloads() { const downloadsList = document.getElementById('downloads-list'); const noDownloads = document.getElementById('no-downloads'); // Filter downloads by selected clients let filteredDownloads = state.downloads; if (state.selectedDownloadClients.length > 0) { // Map indices to client objects, then filter by both client type and instanceId const selectedClients = state.selectedDownloadClients.map(idx => state.downloadClients[idx]).filter(Boolean); filteredDownloads = state.downloads.filter(d => selectedClients.some(c => c.type === d.client && c.id === d.instanceId) ); } // Sort downloads by client order (matching the order in downloadClients) if (state.downloadClients.length > 0) { const clientOrder = new Map(state.downloadClients.map((c, idx) => [c.id, idx])); filteredDownloads = [...filteredDownloads].sort((a, b) => { const orderA = clientOrder.get(a.instanceId) ?? Infinity; const orderB = clientOrder.get(b.instanceId) ?? Infinity; return orderA - orderB; }); } if (filteredDownloads.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(); filteredDownloads.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(); } }); } export function updateDownloadCard(card, download) { // Remove old header-right container if it exists const oldRightSide = card.querySelector('.download-header-right'); if (oldRightSide) { oldRightSide.remove(); } // Remove old user badges directly in header const oldBadges = card.querySelectorAll('.download-header .download-user-badge'); oldBadges.forEach(badge => badge.remove()); // Remove old client logo from header (old structure) const oldLogoInHeader = card.querySelector('.download-header .download-client-logo-wrapper'); if (oldLogoInHeader) { oldLogoInHeader.remove(); } // Remove old client logo from card (new structure) if it exists const oldLogoInCard = card.querySelector('.download-card-logo-wrapper'); if (oldLogoInCard) { oldLogoInCard.remove(); } // Add new right-side container with user badge only const header = card.querySelector('.download-header'); if (header && !header.querySelector('.download-header-right')) { const rightSide = document.createElement('div'); rightSide.className = 'download-header-right'; if (state.showAll && download.tagBadges && download.tagBadges.length > 0) { 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; rightSide.appendChild(badge); } for (const b of matched) { const badge = document.createElement('span'); badge.className = 'download-user-badge'; badge.textContent = b.matchedUser; rightSide.appendChild(badge); } } else if (download.matchedUserTag) { const matchedBadge = document.createElement('span'); matchedBadge.className = 'download-user-badge'; matchedBadge.textContent = download.matchedUserTag; rightSide.appendChild(matchedBadge); } header.appendChild(rightSide); } // Add client logo to card (positioned at bottom right via CSS) if (download.client && !card.querySelector('.download-card-logo-wrapper')) { const clientLogoWrapper = document.createElement('span'); clientLogoWrapper.className = 'download-client-logo-wrapper download-card-logo-wrapper'; const clientLogo = document.createElement('img'); clientLogo.className = 'download-client-logo'; clientLogo.src = `/images/clients/${download.client}.svg`; clientLogo.alt = `${download.instanceName || download.client} icon`; clientLogo.title = download.instanceName || download.client; clientLogo.onerror = () => { clientLogoWrapper.textContent = download.client.charAt(0).toUpperCase(); clientLogoWrapper.classList.add('fallback'); }; clientLogoWrapper.appendChild(clientLogo); card.appendChild(clientLogoWrapper); } // 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); } } } export async function handleBlocklistSearchClick(btn, download) { if (!confirm(`Blocklist "${download.title}" and trigger a new search?\n\nThis will:\n• Remove the download from the download client\n• Add this release to the blocklist\n• Trigger an automatic search for a new release`)) return; btn.disabled = true; btn.textContent = '⏳ Working…'; try { await handleBlocklistSearch(download); btn.textContent = '✓ Done — searching…'; btn.className = 'blocklist-search-btn success'; } catch (err) { console.error('[Blocklist] Error:', err); btn.disabled = false; btn.textContent = '⛔ Blocklist & Search'; btn.className = 'blocklist-search-btn error'; btn.title = `Failed: ${err.message}`; setTimeout(() => { btn.className = 'blocklist-search-btn'; btn.title = 'Remove this release from the download client, add it to the blocklist, and trigger a new automatic search'; }, 4000); } } export 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); } if ((state.isAdmin || download.canBlocklist) && download.arrQueueId) { const blBtn = document.createElement('button'); blBtn.className = 'blocklist-search-btn'; blBtn.textContent = '⛔ Blocklist & Search'; blBtn.title = 'Remove this release from the download client, add it to the blocklist, and trigger a new automatic search'; blBtn.addEventListener('click', () => handleBlocklistSearchClick(blBtn, download)); header.appendChild(blBtn); } // Right side container for user badge only const rightSide = document.createElement('div'); rightSide.className = 'download-header-right'; if (state.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; rightSide.appendChild(badge); } for (const b of matched) { const badge = document.createElement('span'); badge.className = 'download-user-badge'; badge.textContent = b.matchedUser; rightSide.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; rightSide.appendChild(matchedBadge); } header.appendChild(rightSide); // Add client logo to card (positioned at bottom right via CSS) if (download.client) { const clientLogoWrapper = document.createElement('span'); clientLogoWrapper.className = 'download-client-logo-wrapper download-card-logo-wrapper'; const clientLogo = document.createElement('img'); clientLogo.className = 'download-client-logo'; clientLogo.src = `/images/clients/${download.client}.svg`; clientLogo.alt = `${download.instanceName || download.client} icon`; clientLogo.title = download.instanceName || download.client; clientLogo.onerror = () => { // Fallback to text if image fails to load clientLogoWrapper.textContent = download.client.charAt(0).toUpperCase(); clientLogoWrapper.classList.add('fallback'); }; clientLogoWrapper.appendChild(clientLogo); card.appendChild(clientLogoWrapper); } 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 (state.isAdmin && download.arrLink) { series.innerHTML = 'Series: ' + escapeHtml(download.seriesName) + ''; } 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 (state.isAdmin && download.arrLink) { movie.innerHTML = 'Movie: ' + escapeHtml(download.movieName) + ''; } else { movie.textContent = `Movie: ${download.movieName}`; } infoDiv.appendChild(movie); } if (state.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); } // Add client logo if (download.client) { const clientLogoWrapper = document.createElement('span'); clientLogoWrapper.className = 'download-client-logo-wrapper download-card-logo-wrapper'; const clientLogo = document.createElement('img'); clientLogo.className = 'download-client-logo'; clientLogo.src = `/images/clients/${download.client}.svg`; clientLogo.alt = `${download.instanceName || download.client} icon`; clientLogo.title = download.instanceName || download.client; clientLogo.onerror = () => { // Fallback to text if image fails to load clientLogoWrapper.textContent = download.client.charAt(0).toUpperCase(); clientLogoWrapper.classList.add('fallback'); }; clientLogoWrapper.appendChild(clientLogo); header.appendChild(clientLogoWrapper); } 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 (only for torrent clients like qBittorrent) if (download.client && (download.client === 'qbittorrent' || download.client === 'rtorrent') && 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 && download.speed > 0) { const speed = createDetailItem('Speed', formatSpeed(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 (state.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; } export 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 formatDate(dateString) { if (!dateString) return 'N/A'; return new Date(dateString).toLocaleString(); }