Add download client ordering and filtering to active downloads list
All checks were successful
Build and Push Docker Image / build (push) Successful in 22s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m5s
CI / Security audit (push) Successful in 1m26s
CI / Tests & coverage (push) Successful in 1m44s

This commit is contained in:
2026-05-19 23:29:38 +01:00
parent 3e06bdf8cd
commit 720de6688b
6 changed files with 143 additions and 17 deletions

View File

@@ -1,6 +1,8 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
let currentUser = null;
let downloads = [];
let downloadClients = []; // List of download clients from server (for ordering/filtering)
let selectedDownloadClient = localStorage.getItem('sofarr-download-client') || 'all'; // Selected client filter
let isAdmin = false;
let showAll = false;
let csrfToken = null; // double-submit CSRF token, sent as X-CSRF-Token on mutating requests
@@ -30,6 +32,7 @@ document.addEventListener('DOMContentLoaded', () => {
initThemeSwitcher();
initTabs();
initHistoryControls();
initDownloadClientFilter();
initWebhooks();
loadAppVersion();
@@ -118,6 +121,11 @@ function startSSE() {
currentUser = data.user;
isAdmin = !!data.isAdmin;
downloads = data.downloads;
// Store download clients and update filter dropdown
if (data.downloadClients) {
downloadClients = data.downloadClients;
updateDownloadClientFilter();
}
document.getElementById('currentUser').textContent = currentUser || '-';
renderDownloads();
hideError();
@@ -352,28 +360,44 @@ function formatEpisodeInfo(episodes) {
function renderDownloads() {
const downloadsList = document.getElementById('downloads-list');
const noDownloads = document.getElementById('no-downloads');
if (downloads.length === 0) {
// Filter downloads by selected client
let filteredDownloads = downloads;
if (selectedDownloadClient !== 'all') {
filteredDownloads = downloads.filter(d => d.instanceId === selectedDownloadClient);
}
// Sort downloads by client order (matching the order in downloadClients)
if (downloadClients.length > 0) {
const clientOrder = new Map(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();
downloads.forEach(download => {
filteredDownloads.forEach(download => {
const id = download.title;
processedIds.add(id);
const existingCard = existingCards.get(id);
if (existingCard) {
// Update existing card
@@ -384,7 +408,7 @@ function renderDownloads() {
downloadsList.appendChild(card);
}
});
// Remove cards for downloads that no longer exist
existingCards.forEach((card, id) => {
if (!processedIds.has(id)) {
@@ -1022,6 +1046,51 @@ function initHistoryControls() {
}
}
// =============================================================================
// Download Client Filter
// =============================================================================
function initDownloadClientFilter() {
const filterSelect = document.getElementById('download-client-filter');
if (filterSelect) {
// Set initial value from localStorage
filterSelect.value = selectedDownloadClient;
filterSelect.addEventListener('change', () => {
selectedDownloadClient = filterSelect.value;
localStorage.setItem('sofarr-download-client', selectedDownloadClient);
renderDownloads();
});
}
}
function updateDownloadClientFilter() {
const filterSelect = document.getElementById('download-client-filter');
if (!filterSelect || downloadClients.length === 0) return;
// Save current selection
const currentValue = filterSelect.value;
// Clear existing options (except "All clients")
filterSelect.innerHTML = '<option value="all">All clients</option>';
// Add options for each download client
downloadClients.forEach(client => {
const option = document.createElement('option');
option.value = client.id;
option.textContent = client.name;
filterSelect.appendChild(option);
});
// Restore selection if still valid, otherwise default to 'all'
if (currentValue && (currentValue === 'all' || downloadClients.some(c => c.id === currentValue))) {
filterSelect.value = currentValue;
} else {
filterSelect.value = 'all';
selectedDownloadClient = 'all';
localStorage.setItem('sofarr-download-client', 'all');
}
}
function startHistoryRefresh() {
stopHistoryRefresh();
historyRefreshHandle = setInterval(() => loadHistory(), HISTORY_REFRESH_MS);

View File

@@ -144,6 +144,14 @@
<div class="tab-panel" id="tab-downloads">
<div class="downloads-container">
<div class="downloads-header">
<div class="downloads-controls">
<label class="download-client-label" for="download-client-filter">Download client:</label>
<select id="download-client-filter" class="download-client-select">
<option value="all">All clients</option>
</select>
</div>
</div>
<div id="no-downloads" class="no-downloads" style="display: none;">
<p>No downloads found for your user.</p>
<p>Make sure your shows and movies are tagged with your username in Sonarr/Radarr.</p>

View File

@@ -662,6 +662,42 @@ body {
padding: 0;
}
/* Downloads header and controls */
.downloads-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.downloads-controls {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: nowrap;
}
.download-client-label {
font-size: 0.85rem;
color: var(--text-secondary);
}
.download-client-select {
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--surface);
color: var(--text-primary);
font-size: 0.85rem;
cursor: pointer;
}
.download-client-select:focus {
outline: none;
border-color: var(--accent);
}
.history-header {
display: flex;
align-items: center;

View File

@@ -8,6 +8,7 @@ const { mapTorrentToDownload } = require('../utils/qbittorrent');
const cache = require('../utils/cache');
const { pollAllServices, getLastPollTimings, onPollComplete, offPollComplete, POLLING_ENABLED } = require('../utils/poller');
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
const downloadClientRegistry = require('../utils/downloadClients');
const sanitizeError = require('../utils/sanitizeError');
@@ -1109,7 +1110,7 @@ router.get('/stream', requireAuth, async (req, res) => {
const allTags = extractAllTags(series.tags, sonarrTagMap);
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbmissing, size: slot.size, speed: slotState.speed, eta: slot.timeleft, seriesName: series.title, episodes: gatherEpisodes(nzbNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbmissing, size: slot.size, speed: slotState.speed, eta: slot.timeleft, seriesName: series.title, episodes: gatherEpisodes(nzbNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined, client: 'sabnzbd', instanceId: slot.instanceId || 'sabnzbd-default', instanceName: slot.instanceName || 'SABnzbd' };
const issues = getImportIssues(sonarrMatch);
if (issues) dlObj.importIssues = issues;
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); dlObj.arrQueueId = sonarrMatch.id; dlObj.arrType = 'sonarr'; dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null; dlObj.arrInstanceKey = sonarrMatch._instanceKey || null; dlObj.arrContentId = sonarrMatch.episodeId || null; dlObj.arrContentType = 'episode'; }
@@ -1127,7 +1128,7 @@ router.get('/stream', requireAuth, async (req, res) => {
const allTags = extractAllTags(movie.tags, radarrTagMap);
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
const dlObj = { type: 'movie', title: nzbName, coverArt: getCoverArt(movie), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbmissing, size: slot.size, speed: slotState.speed, eta: slot.timeleft, movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
const dlObj = { type: 'movie', title: nzbName, coverArt: getCoverArt(movie), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbmissing, size: slot.size, speed: slotState.speed, eta: slot.timeleft, movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined, client: 'sabnzbd', instanceId: slot.instanceId || 'sabnzbd-default', instanceName: slot.instanceName || 'SABnzbd' };
const issues = getImportIssues(radarrMatch);
if (issues) dlObj.importIssues = issues;
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = getRadarrLink(movie); dlObj.arrQueueId = radarrMatch.id; dlObj.arrType = 'radarr'; dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null; dlObj.arrInstanceKey = radarrMatch._instanceKey || null; dlObj.arrContentId = radarrMatch.movieId || null; dlObj.arrContentType = 'movie'; }
@@ -1156,7 +1157,7 @@ router.get('/stream', requireAuth, async (req, res) => {
const allTags = extractAllTags(series.tags, sonarrTagMap);
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slot.status, size: slot.size, completedAt: slot.completed_time, seriesName: series.title, episodes: gatherEpisodes(nzbNameLower, sonarrHistory.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slot.status, size: slot.size, completedAt: slot.completed_time, seriesName: series.title, episodes: gatherEpisodes(nzbNameLower, sonarrHistory.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined, client: 'sabnzbd', instanceId: slot.instanceId || 'sabnzbd-default', instanceName: slot.instanceName || 'SABnzbd' };
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); }
userDownloads.push(dlObj);
}
@@ -1173,7 +1174,7 @@ router.get('/stream', requireAuth, async (req, res) => {
const allTags = extractAllTags(movie.tags, radarrTagMap);
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
const dlObj = { type: 'movie', title: nzbName, coverArt: getCoverArt(movie), status: slot.status, size: slot.size, completedAt: slot.completed_time, movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
const dlObj = { type: 'movie', title: nzbName, coverArt: getCoverArt(movie), status: slot.status, size: slot.size, completedAt: slot.completed_time, movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined, client: 'sabnzbd', instanceId: slot.instanceId || 'sabnzbd-default', instanceName: slot.instanceName || 'SABnzbd' };
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = getRadarrLink(movie); }
userDownloads.push(dlObj);
}
@@ -1259,7 +1260,13 @@ router.get('/stream', requireAuth, async (req, res) => {
if (userDownloads.length > 0) {
console.log(`[SSE] Download titles: ${userDownloads.map(d => d.title).join(', ')}`);
}
res.write(`data: ${JSON.stringify({ user: user.name, isAdmin, downloads: userDownloads })}\n\n`);
// Get download clients list for ordering/filtering
const downloadClients = downloadClientRegistry.getAllClients().map(c => ({
id: c.getInstanceId(),
name: c.name,
type: c.getClientType()
}));
res.write(`data: ${JSON.stringify({ user: user.name, isAdmin, downloads: userDownloads, downloadClients })}\n\n`);
} catch (err) {
console.error('[SSE] Error building payload:', sanitizeError(err));
}

View File

@@ -178,10 +178,12 @@ async function pollAllServices() {
cat: d.category,
labels: d.tags.join(','),
added: d.addedOn ? Math.floor(new Date(d.addedOn).getTime() / 1000) : null,
raw: d.raw
raw: d.raw,
instanceId: d.instanceId,
instanceName: d.instanceName
}))
};
const sabHistoryLegacy = {
slots: sabHistory.map(d => ({
nzo_id: d.id,
@@ -191,7 +193,9 @@ async function pollAllServices() {
cat: d.category,
labels: d.tags.join(','),
added: d.addedOn ? Math.floor(new Date(d.addedOn).getTime() / 1000) : null,
raw: d.raw
raw: d.raw,
instanceId: d.instanceId,
instanceName: d.instanceName
}))
};

View File

@@ -102,6 +102,8 @@ function mapTorrentToDownload(torrent) {
return {
type: 'torrent',
title: torrent.name,
client: 'qbittorrent',
instanceId: torrent.instanceId,
instanceName: torrent.instanceName,
status: status,
progress: progress.toFixed(1),