Files
sofarr/public/app.js
Gronod f500f4db3b feat: fix download-to-user matching, add cover art to downloads
- Fix seriesMap key (use Sonarr internal id, not tvdbId)
- Fix Sonarr tag resolution (use tag map like Radarr)
- Use sourceTitle for history record matching
- Fall back to embedded movie/series objects when API timeouts
- Add includeMovie/includeSeries params to queue/history API calls
- Add coverArt field to all download responses (TMDB poster URLs)
- Add cover art display to frontend download cards
- Fix user-summary route to use instance config and tag maps
2026-05-15 14:54:21 +01:00

481 lines
14 KiB
JavaScript

let currentUser = null;
let downloads = [];
let refreshInterval = null;
let currentRefreshRate = 5000; // default 5 seconds
// Check authentication on load
document.addEventListener('DOMContentLoaded', () => {
checkAuthentication();
document.getElementById('login-form').addEventListener('submit', handleLogin);
document.getElementById('logout-btn').addEventListener('click', handleLogout);
document.getElementById('refresh-rate').addEventListener('change', handleRefreshRateChange);
});
function startAutoRefresh() {
if (refreshInterval) clearInterval(refreshInterval);
if (currentRefreshRate > 0) {
refreshInterval = setInterval(() => fetchUserDownloads(false), currentRefreshRate);
}
}
function handleRefreshRateChange(e) {
const rate = parseInt(e.target.value);
currentRefreshRate = rate;
startAutoRefresh();
}
function stopAutoRefresh() {
if (refreshInterval) {
clearInterval(refreshInterval);
refreshInterval = null;
}
}
async function checkAuthentication() {
try {
const response = await fetch('/api/auth/me');
const data = await response.json();
if (data.authenticated) {
currentUser = data.user;
showDashboard();
fetchUserDownloads(true);
startAutoRefresh();
} else {
showLogin();
}
} catch (err) {
console.error('Authentication check failed:', err);
showLogin();
}
}
async function handleLogin(e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (data.success) {
currentUser = data.user;
showDashboard();
fetchUserDownloads(true);
startAutoRefresh();
} else {
showLoginError(data.error || 'Login failed');
}
} catch (err) {
showLoginError('Login failed. Please try again.');
console.error(err);
}
}
async function handleLogout() {
try {
stopAutoRefresh();
await fetch('/api/auth/logout', {
method: 'POST'
});
currentUser = 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 || '-';
}
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';
}
async function fetchUserDownloads(isInitialLoad = false) {
if (isInitialLoad) {
showLoading();
}
hideError();
try {
const response = await fetch('/api/dashboard/user-downloads');
const data = await response.json();
currentUser = data.user;
downloads = data.downloads;
// Debug: log first download to see what fields are present
if (downloads.length > 0) {
console.log('[Dashboard] Download data:', JSON.stringify(downloads[0]));
}
document.getElementById('currentUser').textContent = currentUser || '-';
renderDownloads();
} catch (err) {
showError('Failed to fetch downloads. Make sure all services are configured.');
console.error(err);
} finally {
if (isInitialLoad) {
hideLoading();
}
}
}
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');
coverImg.src = 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);
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';
series.textContent = `Series: ${download.seriesName}`;
infoDiv.appendChild(series);
}
if (download.movieName) {
const movie = document.createElement('p');
movie.className = 'download-movie';
movie.textContent = `Movie: ${download.movieName}`;
infoDiv.appendChild(movie);
}
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);
}
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 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';
}