Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f10414498 | |||
| 1e3926b206 | |||
| 5fde69fcf5 | |||
| a562cfe9aa | |||
| 8549746721 | |||
| 63fc370262 | |||
| 6362441dd5 | |||
| 76f9e87b44 | |||
| 8c461de72a | |||
| d11f11be69 | |||
| 05d11975e6 | |||
| cd3480c0ce | |||
| 712c98d817 | |||
| ff7ace9f4f | |||
| 73500751a0 | |||
| 82a9df134b | |||
| 67fa79796b | |||
| f06d945358 | |||
| f5883d4929 | |||
| 80cf3eaa39 | |||
| 1ab7e52167 | |||
| 544c168b82 | |||
| 747a14ebd3 | |||
| 49d66c07ee | |||
| be791ed044 | |||
| 7195a09562 | |||
| 720de6688b |
+43
-1
@@ -460,6 +460,21 @@ When a browser opens `GET /api/dashboard/stream`:
|
||||
|
||||
The browser's native `EventSource` API handles reconnection automatically on network interruption.
|
||||
|
||||
**SSE Payload Structure**
|
||||
|
||||
```javascript
|
||||
{
|
||||
user: string, // Username
|
||||
isAdmin: boolean, // Admin flag
|
||||
downloads: DownloadObject[], // Matched download objects (see Section 5.4)
|
||||
downloadClients: { // Configured download clients for ordering/filtering
|
||||
id: string, // Instance identifier
|
||||
name: string, // Instance display name
|
||||
type: string // Client type ('sabnzbd', 'qbittorrent', 'transmission', 'rtorrent')
|
||||
}[]
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 Download Matching Pipeline
|
||||
|
||||
For each connected user the server:
|
||||
@@ -495,6 +510,14 @@ Users are matched to downloads via Sonarr/Radarr tags:
|
||||
1. **Exact match** — tag label (lowercased) === username (lowercased).
|
||||
2. **Sanitised match** — handles Ombi tag mangling: `sanitizeTagLabel()` converts to lowercase, replaces non-alphanumeric chars with hyphens, collapses runs, trims.
|
||||
|
||||
#### Client ordering and filtering
|
||||
|
||||
Matched download objects include `client`, `instanceId`, and `instanceName` fields. The frontend:
|
||||
1. Receives a `downloadClients` array from the SSE payload with all configured clients in configuration order
|
||||
2. Displays a multi-select filter allowing users to choose which clients to view
|
||||
3. Sorts downloads by client order (downloads from the first configured client appear first)
|
||||
4. Filters downloads to show only those from selected client instances
|
||||
|
||||
#### Matched download object fields
|
||||
|
||||
| Field | Type | Description |
|
||||
@@ -523,6 +546,9 @@ Users are matched to downloads via Sonarr/Radarr tags:
|
||||
| `arrInstanceKey` | string/null | (Admin) API key for the *arr instance |
|
||||
| `arrContentId` | number/null | (Admin) `episodeId` or `movieId` for triggering a new search |
|
||||
| `arrContentType` | `'episode'`/`'movie'`/null | (Admin) Content type for search command |
|
||||
| `client` | string | Download client type ('sabnzbd', 'qbittorrent', 'transmission', 'rtorrent') |
|
||||
| `instanceId` | string | Instance identifier matching the configured client ID |
|
||||
| `instanceName` | string | Instance display name from configuration |
|
||||
| `addedOn` | number/null | (qBittorrent) Unix timestamp when torrent was added |
|
||||
| `availableForUpgrade` | boolean/undefined | (History) `true` when outcome is `failed` but content is on disk |
|
||||
|
||||
@@ -683,7 +709,7 @@ stateDiagram-v2
|
||||
| `handleLogin()` | Authenticate, fade login → splash → dashboard |
|
||||
| `startSSE()` | Open `EventSource` to `/stream`; handle incoming data |
|
||||
| `stopSSE()` | Close `EventSource` and cancel reconnect timer |
|
||||
| `renderDownloads()` | Diff-based card rendering (create/update/remove) |
|
||||
| `renderDownloads()` | Diff-based card rendering (create/update/remove); filters by selected download clients; sorts by client order |
|
||||
| `createDownloadCard()` | Build DOM for a single card; renders tag badges, import-issue badge, blocklist button |
|
||||
| `updateDownloadCard()` | Update existing card in-place (progress, speed, etc.) |
|
||||
| `handleBlocklistSearch()` | Confirm dialog → POST `/blocklist-search` → update button state |
|
||||
@@ -698,6 +724,22 @@ stateDiagram-v2
|
||||
- **Regular user view** — a single accent-coloured badge showing the tag label that matched the current user's username (via `matchedUserTag`).
|
||||
- **Admin `showAll` view** — all tags on the download are rendered using `tagBadges[]`: tags with no matching Emby user → amber badge (leftmost); tags matched to a known Emby user → accent badge showing the Emby display name (rightmost).
|
||||
|
||||
#### Download Client Filter
|
||||
|
||||
The Active Downloads tab includes a multi-select dropdown filter that allows users to:
|
||||
- View all download clients with their type displayed as "Client Name (type)"
|
||||
- Select multiple clients to filter the downloads list
|
||||
- Use "Select All" / "Deselect All" buttons for bulk operations
|
||||
- Persist selection across sessions via localStorage
|
||||
|
||||
Downloads are sorted by client order (matching the configuration order) and filtered by the selected client IDs.
|
||||
|
||||
Related functions:
|
||||
- `initDownloadClientFilter()` — Sets up dropdown toggle, click-outside handler, Select/Deselect All buttons
|
||||
- `updateDownloadClientFilter()` — Populates checkbox list with client name + type badges
|
||||
- `toggleClientSelection()` — Updates selection array and localStorage
|
||||
- `updateSelectedCountDisplay()` — Updates button text to show "All clients" / "1 selected" / "N selected"
|
||||
|
||||
---
|
||||
|
||||
## 8. Directory Structure
|
||||
|
||||
@@ -6,6 +6,40 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
||||
|
||||
---
|
||||
|
||||
## [1.5.5] - 2026-05-20
|
||||
|
||||
### Added
|
||||
|
||||
- **Download client logos** — Added SVG logos for all supported download clients (SABnzbd, qBittorrent, Transmission, rTorrent, Deluge). Logos appear in the download client filter picker and on download cards.
|
||||
- **Download client logo in filter picker** — Multi-select download client filter now displays client logos alongside names for visual identification.
|
||||
- **Download client logo in download cards** — Download cards now display the client logo in the bottom-right corner (32×32px). Positioned absolutely within the card.
|
||||
- **SABnzbd speed display** — SABnzbd downloads now display the overall queue speed for the currently active download only. Speed is fetched from the client status API and applied to the downloading slot.
|
||||
- **Speed formatting** — Speed values are now formatted with appropriate units (B/s, KB/s, MB/s, GB/s) instead of raw bytes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Missing pieces display for SABnzbd** — Removed incorrect "missing x of y" text display for SABnzbd downloads. This information is only relevant for torrent clients (qBittorrent, rTorrent) and is now only shown for those clients.
|
||||
- **Logo duplication on page reload** — Fixed download client logos and user tags appearing twice during page load. Updated `updateDownloadCard()` to remove old elements before adding new ones.
|
||||
- **Logo positioning** — Fixed download client logos appearing stacked at bottom-right of browser window instead of bottom-right of each card. Added `position: relative` to `.download-card` to provide proper positioning context.
|
||||
|
||||
---
|
||||
|
||||
## [1.5.4] - 2026-05-19
|
||||
|
||||
### Added
|
||||
|
||||
- **Multi-select download client filter** — Active Downloads tab now includes a dropdown filter that allows users to select multiple download clients. Shows client name and type (e.g., "Main (qbitt)"). Includes Select All / Deselect All buttons. Selection persists across sessions via localStorage.
|
||||
- **Download client ordering** — Downloads are now sorted by client order (matching the configuration order). Downloads from the first configured client appear first.
|
||||
- **Client metadata in downloads** — Download objects now include `client`, `instanceId`, and `instanceName` fields for client identification and filtering.
|
||||
- **SSE payload extension** — SSE stream now includes `downloadClients` array with all configured clients for UI ordering/filtering.
|
||||
- **Automatic migration** — Existing single-select filter selection automatically migrates to new multi-select format on first load.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **SABnzbd size and speed display** — Fixed SABnzbd downloads showing undefined size and speed values. Now correctly uses `slot.mb` for size calculation and `slot.kbpersec` for per-slot speed from cached data.
|
||||
|
||||
---
|
||||
|
||||
## [1.5.3] - 2026-05-19
|
||||
|
||||
### Fixed
|
||||
|
||||
+5
-1
@@ -44,13 +44,17 @@ services:
|
||||
volumes:
|
||||
# Persistent volume for token store and log file
|
||||
- sofarr-data:/app/data
|
||||
# Mount code for development (comment out in production)
|
||||
- ./server:/app/server
|
||||
- ./public:/app/public
|
||||
# Mount your own TLS certificate and key (optional — snakeoil used if omitted)
|
||||
# - /path/to/your/server.crt:/app/certs/server.crt:ro
|
||||
# - /path/to/your/server.key:/app/certs/server.key:ro
|
||||
# Run as the built-in non-root 'node' user (UID/GID 1000)
|
||||
user: "1000:1000"
|
||||
# Read-only root filesystem; only the data volume is writable
|
||||
read_only: true
|
||||
# Comment out for development when mounting code volumes
|
||||
# read_only: true
|
||||
tmpfs:
|
||||
- /tmp # Node.js needs a writable /tmp
|
||||
security_opt:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sofarr",
|
||||
"version": "1.5.3",
|
||||
"version": "1.5.5",
|
||||
"description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish",
|
||||
"main": "server/index.js",
|
||||
"scripts": {
|
||||
|
||||
+390
-16
@@ -1,11 +1,35 @@
|
||||
// Copyright (c) 2026 Gordon Bolton. MIT License.
|
||||
let currentUser = null;
|
||||
let downloads = [];
|
||||
let downloadClients = []; // List of download clients from server (for ordering/filtering)
|
||||
let selectedDownloadClients = []; // Array of selected client IDs for multi-select filter
|
||||
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
|
||||
|
||||
// Migration from old single-select to new multi-select format
|
||||
(function migrateDownloadClientFilter() {
|
||||
const oldSelection = localStorage.getItem('sofarr-download-client');
|
||||
if (oldSelection && oldSelection !== 'all') {
|
||||
try {
|
||||
selectedDownloadClients = [oldSelection];
|
||||
localStorage.setItem('sofarr-download-clients', JSON.stringify(selectedDownloadClients));
|
||||
localStorage.removeItem('sofarr-download-client');
|
||||
} catch (e) {
|
||||
console.error('[Migration] Failed to migrate download client filter:', e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const newSelection = localStorage.getItem('sofarr-download-clients');
|
||||
selectedDownloadClients = newSelection ? JSON.parse(newSelection) : [];
|
||||
} catch (e) {
|
||||
console.error('[Migration] Failed to load download client filter:', e);
|
||||
selectedDownloadClients = [];
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
// History section state
|
||||
let historyDays = parseInt(localStorage.getItem('sofarr-history-days'), 10) || 7;
|
||||
let historyRefreshHandle = null;
|
||||
@@ -30,6 +54,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
initThemeSwitcher();
|
||||
initTabs();
|
||||
initHistoryControls();
|
||||
initDownloadClientFilter();
|
||||
initWebhooks();
|
||||
loadAppVersion();
|
||||
|
||||
@@ -118,6 +143,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 +382,48 @@ 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 clients
|
||||
let filteredDownloads = downloads;
|
||||
if (selectedDownloadClients.length > 0) {
|
||||
// Map indices to client objects, then filter by both client type and instanceId
|
||||
const selectedClients = selectedDownloadClients.map(idx => downloadClients[idx]).filter(Boolean);
|
||||
filteredDownloads = 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 (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 +434,7 @@ function renderDownloads() {
|
||||
downloadsList.appendChild(card);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Remove cards for downloads that no longer exist
|
||||
existingCards.forEach((card, id) => {
|
||||
if (!processedIds.has(id)) {
|
||||
@@ -394,6 +444,78 @@ function renderDownloads() {
|
||||
}
|
||||
|
||||
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 (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) {
|
||||
@@ -532,7 +654,7 @@ function createDownloadCard(download) {
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'download-header';
|
||||
|
||||
|
||||
const type = document.createElement('span');
|
||||
type.className = `download-type ${download.type}`;
|
||||
if (download.type === 'series') {
|
||||
@@ -545,11 +667,11 @@ function createDownloadCard(download) {
|
||||
} 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);
|
||||
|
||||
@@ -569,6 +691,58 @@ function createDownloadCard(download) {
|
||||
blBtn.addEventListener('click', () => handleBlocklistSearch(blBtn, download));
|
||||
header.appendChild(blBtn);
|
||||
}
|
||||
|
||||
// Right side container for user badge only
|
||||
const rightSide = document.createElement('div');
|
||||
rightSide.className = 'download-header-right';
|
||||
|
||||
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;
|
||||
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';
|
||||
@@ -626,6 +800,26 @@ function createDownloadCard(download) {
|
||||
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';
|
||||
@@ -671,8 +865,8 @@ function createDownloadCard(download) {
|
||||
progressText.textContent = download.progress + '%';
|
||||
valueDiv.appendChild(progressText);
|
||||
|
||||
// Missing pieces text
|
||||
if (missingMb > 0 && totalMb > 0) {
|
||||
// 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)`;
|
||||
@@ -684,8 +878,8 @@ function createDownloadCard(download) {
|
||||
details.appendChild(progressItem);
|
||||
}
|
||||
|
||||
if (download.speed) {
|
||||
const speed = createDetailItem('Speed', download.speed);
|
||||
if (download.speed && download.speed > 0) {
|
||||
const speed = createDetailItem('Speed', formatSpeed(download.speed));
|
||||
details.appendChild(speed);
|
||||
}
|
||||
|
||||
@@ -742,6 +936,21 @@ function createDownloadCard(download) {
|
||||
return card;
|
||||
}
|
||||
|
||||
function formatSpeed(bytesPerSecond) {
|
||||
if (!bytesPerSecond || bytesPerSecond === 0) return '0 B/s';
|
||||
|
||||
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
|
||||
let value = bytesPerSecond;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${value.toFixed(2)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function createDetailItem(label, value) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'detail-item';
|
||||
@@ -1022,6 +1231,171 @@ function initHistoryControls() {
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Download Client Filter
|
||||
// =============================================================================
|
||||
|
||||
function initDownloadClientFilter() {
|
||||
const dropdownBtn = document.getElementById('download-client-dropdown-btn');
|
||||
const dropdown = document.getElementById('download-client-dropdown');
|
||||
const selectAllBtn = document.getElementById('download-client-select-all');
|
||||
const deselectAllBtn = document.getElementById('download-client-deselect-all');
|
||||
|
||||
if (dropdownBtn && dropdown) {
|
||||
// Toggle dropdown
|
||||
dropdownBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const isOpen = dropdown.classList.toggle('open');
|
||||
dropdownBtn.classList.toggle('open', isOpen);
|
||||
dropdownBtn.setAttribute('aria-expanded', isOpen);
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!dropdown.contains(e.target) && !dropdownBtn.contains(e.target)) {
|
||||
dropdown.classList.remove('open');
|
||||
dropdownBtn.classList.remove('open');
|
||||
dropdownBtn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
|
||||
// Close dropdown on Escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
dropdown.classList.remove('open');
|
||||
dropdownBtn.classList.remove('open');
|
||||
dropdownBtn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (selectAllBtn) {
|
||||
selectAllBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
selectedDownloadClients = downloadClients.map((_, idx) => idx);
|
||||
localStorage.setItem('sofarr-download-clients', JSON.stringify(selectedDownloadClients));
|
||||
updateDownloadClientFilter();
|
||||
renderDownloads();
|
||||
});
|
||||
}
|
||||
|
||||
if (deselectAllBtn) {
|
||||
deselectAllBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
selectedDownloadClients = [];
|
||||
localStorage.setItem('sofarr-download-clients', JSON.stringify(selectedDownloadClients));
|
||||
updateDownloadClientFilter();
|
||||
renderDownloads();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateDownloadClientFilter() {
|
||||
const optionsContainer = document.getElementById('download-client-options');
|
||||
if (!optionsContainer) return;
|
||||
|
||||
// Clear existing options
|
||||
optionsContainer.innerHTML = '';
|
||||
|
||||
if (downloadClients.length === 0) {
|
||||
optionsContainer.innerHTML = '<div class="download-client-empty">No clients available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Migrate old client.id values to indices
|
||||
if (selectedDownloadClients.length > 0 && typeof selectedDownloadClients[0] === 'string') {
|
||||
const migratedIndices = [];
|
||||
selectedDownloadClients.forEach(clientId => {
|
||||
const index = downloadClients.findIndex(c => c.id === clientId);
|
||||
if (index !== -1) {
|
||||
migratedIndices.push(index);
|
||||
}
|
||||
});
|
||||
selectedDownloadClients = migratedIndices;
|
||||
localStorage.setItem('sofarr-download-clients', JSON.stringify(selectedDownloadClients));
|
||||
}
|
||||
|
||||
// Add checkboxes for each download client
|
||||
downloadClients.forEach((client, index) => {
|
||||
const option = document.createElement('div');
|
||||
option.className = 'download-client-option';
|
||||
|
||||
const checkboxId = `download-client-checkbox-${index}`;
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.className = 'download-client-checkbox';
|
||||
checkbox.value = index; // Use index as unique identifier
|
||||
checkbox.checked = selectedDownloadClients.includes(index);
|
||||
checkbox.id = checkboxId;
|
||||
|
||||
// Toggle selection when checkbox changes
|
||||
checkbox.addEventListener('change', (e) => {
|
||||
toggleClientSelection(index, e.target.checked);
|
||||
});
|
||||
|
||||
// Add client icon
|
||||
const iconWrapper = document.createElement('span');
|
||||
iconWrapper.className = 'download-client-icon';
|
||||
|
||||
const iconImg = document.createElement('img');
|
||||
iconImg.src = `/images/clients/${client.type}.svg`;
|
||||
iconImg.alt = `${client.name} icon`;
|
||||
iconImg.onerror = () => {
|
||||
// Fallback to text if image fails to load
|
||||
iconWrapper.textContent = client.type.charAt(0).toUpperCase();
|
||||
iconWrapper.classList.add('fallback');
|
||||
};
|
||||
|
||||
iconWrapper.appendChild(iconImg);
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.className = 'download-client-option-label';
|
||||
label.htmlFor = checkboxId;
|
||||
label.textContent = client.name;
|
||||
|
||||
const typeBadge = document.createElement('span');
|
||||
typeBadge.className = 'download-client-type';
|
||||
typeBadge.textContent = client.type;
|
||||
|
||||
option.appendChild(checkbox);
|
||||
option.appendChild(iconWrapper);
|
||||
option.appendChild(label);
|
||||
option.appendChild(typeBadge);
|
||||
|
||||
optionsContainer.appendChild(option);
|
||||
});
|
||||
|
||||
// Update button text
|
||||
updateSelectedCountDisplay();
|
||||
}
|
||||
|
||||
function toggleClientSelection(clientId, isSelected) {
|
||||
if (isSelected) {
|
||||
if (!selectedDownloadClients.includes(clientId)) {
|
||||
selectedDownloadClients.push(clientId);
|
||||
}
|
||||
} else {
|
||||
selectedDownloadClients = selectedDownloadClients.filter(id => id !== clientId);
|
||||
}
|
||||
localStorage.setItem('sofarr-download-clients', JSON.stringify(selectedDownloadClients));
|
||||
updateSelectedCountDisplay();
|
||||
renderDownloads();
|
||||
}
|
||||
|
||||
function updateSelectedCountDisplay() {
|
||||
const selectedText = document.getElementById('download-client-selected-text');
|
||||
if (!selectedText) return;
|
||||
|
||||
if (selectedDownloadClients.length === 0) {
|
||||
selectedText.textContent = 'All clients';
|
||||
} else if (selectedDownloadClients.length === 1) {
|
||||
const client = downloadClients[selectedDownloadClients[0]];
|
||||
selectedText.textContent = client ? client.name : '1 selected';
|
||||
} else {
|
||||
selectedText.textContent = `${selectedDownloadClients.length} selected`;
|
||||
}
|
||||
}
|
||||
|
||||
function startHistoryRefresh() {
|
||||
stopHistoryRefresh();
|
||||
historyRefreshHandle = setInterval(() => loadHistory(), HISTORY_REFRESH_MS);
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="m256.1 2.6 142 217.2c91 139.2-4.7 289.6-142.1 289.6S22.8 358.9 113.9 219.8z" style="fill-rule:evenodd;clip-rule:evenodd;fill:#4c90e8;stroke:#094491;stroke-width:3.1904"/><path d="M306.7 255.2c-77.6-31.7-118.4 49.2-105.4 87C229.5 424.4 335.8 445 414.2 308c0 0 .8 16.5 1.7 24.4 10.5 99.9-73.5 163.4-158.6 162.2s-111.1-34.3-136.2-72.3C80.4 360.6 90.5 260 145.2 212.9c62.5-51.6 131.2-24 161.5 42.3" style="fill-rule:evenodd;clip-rule:evenodd;fill:#094491"/><path d="M257.9 225.3c-87 2.1-103.5 102.3-79.4 145.3 33.5 59.7 84.3 71.2 153.8 49.1-39.7 43.2-121.2 54.6-176.5-7.1-38.1-42.4-41.4-101.6-15-151.1 26.5-49.4 79.2-63.2 117.1-36.2" style="fill-rule:evenodd;clip-rule:evenodd;fill:#83b8f9"/></svg>
|
||||
|
After Width: | Height: | Size: 786 B |
@@ -0,0 +1 @@
|
||||
<svg height="1024" viewBox="0 0 1024 1024" width="1024" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="348.2829" x2="782.05951" y1="0" y2="786.48322"><stop offset="0" stop-color="#72b4f5"/><stop offset="1" stop-color="#356ebf"/></linearGradient><g fill="none" fill-rule="evenodd" transform="matrix(.97656268 0 0 .9765624 11.999908 12.000051)"><circle cx="512" cy="512" fill="url(#a)" r="496" stroke="#daefff" stroke-width="32"/><path d="m712.898 332.399q66.657 0 103.38 45.671 37.03 45.364 37.03 128.684 0 83.32-37.34 129.61-37.03 45.98-103.07 45.98-33.02 0-60.484-12.035-27.156-12.344-45.672-37.649h-3.703l-10.8 43.512h-36.724v-480.172h51.227v116.65q0 39.191-2.469 70.359h2.47q35.796-50.61 106.155-50.61zm-7.406 42.894q-52.46 0-75.605 30.242-23.145 29.934-23.145 101.219 0 71.285 23.762 102.145 23.761 30.55 76.222 30.55 47.215 0 70.36-34.254 23.144-34.562 23.144-99.058 0-66.04-23.144-98.442-23.145-32.402-71.594-32.402z" fill="#fff"/><path d="m317.273 639.45q51.227 0 74.68-27.466 23.453-27.464 24.996-92.578v-11.418q0-70.976-24.07-102.144-24.07-31.168-76.223-31.168-45.055 0-69.125 35.18-23.762 34.87-23.762 98.75 0 63.879 23.454 97.515 23.761 33.328 70.05 33.328zm-7.715 42.894q-65.421 0-102.144-45.98-36.723-45.981-36.723-128.376 0-83.011 37.032-129.609 37.03-46.598 103.07-46.598 69.433 0 106.773 52.461h2.778l7.406-46.289h40.426v490.047h-51.227v-144.73q0-30.86 3.395-52.461h-4.012q-35.488 51.535-106.774 51.535z" fill="#c8e8ff"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000"><path fill="#f5f5f5000" stroke="#f5f5f5" stroke-linejoin="round" stroke-width="74" d="M200.4 39.3h598.1v437.8h161l-460.1 483L39.4 477h161z"/><path fill="#ffb300" fill-rule="evenodd" d="M200.4 39.3h598.1v437.8h161l-460.1 483-460-483h161z"/><path fill="#ffca28" fill-rule="evenodd" d="M499.4 960.2 201.1 39.4h596.7z"/><path fill="#f5f5f5000" stroke="#f5f5f5" stroke-linecap="round" stroke-linejoin="round" stroke-width="74" d="M329.2 843.5H83v-51.8h146.1v-45.9H83V596.9h246.2v51.5H183.1v45.9h146.1zm292.2 0H375.2V694.3h146.1v-45.9H375.2v-51.5h246.2zm-146.1-97.8h46v46h-46zm192.1 97.8v-344h100.1v97.4h146.1v246.6zm100.1-195.2h46v143.4h-46z"/><path fill="#0f0f0f" fill-rule="evenodd" d="M329.2 843.5H83v-51.8h146.1v-45.9H83V596.9h246.2v51.5H183.1v45.9h146.1zm292.2 0H375.2V694.3h146.1v-45.9H375.2v-51.5h246.2zm-146.1-51.8h46v-46h-46zm192.1 51.9v-344h100.1V597h146.1v246.6zm100.1-51.9h46V648.4h-46z"/></svg>
|
||||
|
After Width: | Height: | Size: 966 B |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.8 KiB |
@@ -144,6 +144,26 @@
|
||||
|
||||
<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>
|
||||
<div class="download-client-filter" id="download-client-filter">
|
||||
<button class="download-client-dropdown-btn" id="download-client-dropdown-btn" type="button" aria-expanded="false">
|
||||
<span id="download-client-selected-text">All clients</span>
|
||||
<span class="dropdown-arrow">▼</span>
|
||||
</button>
|
||||
<div class="download-client-dropdown" id="download-client-dropdown">
|
||||
<div class="download-client-dropdown-header">
|
||||
<button class="download-client-dropdown-btn-small" id="download-client-select-all" type="button">Select All</button>
|
||||
<button class="download-client-dropdown-btn-small" id="download-client-deselect-all" type="button">Deselect All</button>
|
||||
</div>
|
||||
<div class="download-client-options" id="download-client-options">
|
||||
<!-- Options will be populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
+253
-1
@@ -373,6 +373,7 @@ body {
|
||||
align-items: flex-start;
|
||||
transition: box-shadow 0.2s, background 0.3s;
|
||||
background: var(--surface);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.download-card:hover {
|
||||
@@ -662,6 +663,212 @@ 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);
|
||||
}
|
||||
|
||||
/* Multi-select dropdown container */
|
||||
.download-client-filter {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.download-client-dropdown-btn {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
min-width: 140px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.download-client-dropdown-btn:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.download-client-dropdown-btn:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.download-client-dropdown-btn .dropdown-arrow {
|
||||
font-size: 0.75rem;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.download-client-dropdown-btn.open .dropdown-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.download-client-count {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Dropdown panel */
|
||||
.download-client-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.download-client-dropdown.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Dropdown header with Select All/Deselect All buttons */
|
||||
.download-client-dropdown-header {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--surface);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.download-client-dropdown-btn-small {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.download-client-dropdown-btn-small:hover {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Client option row */
|
||||
.download-client-option {
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.download-client-option:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.download-client-checkbox {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.download-client-option-label {
|
||||
flex: 1;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.download-client-type {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
background: var(--surface-alt);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.download-client-empty {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Client icon */
|
||||
.download-client-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.download-client-icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.download-client-icon.fallback {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--surface-alt);
|
||||
border-radius: 3px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.history-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1196,7 +1403,6 @@ body {
|
||||
text-transform: capitalize;
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -1206,6 +1412,52 @@ body {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* Download client logo in card */
|
||||
.download-header-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.download-client-logo-wrapper {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Card-specific logo wrapper positioned at bottom right */
|
||||
.download-card-logo-wrapper {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.download-client-logo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.download-client-logo-wrapper.fallback {
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
background: var(--surface-alt);
|
||||
border-radius: 2px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.download-card-logo-wrapper.fallback {
|
||||
font-size: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ===== Status Button ===== */
|
||||
.status-btn {
|
||||
padding: 4px 12px;
|
||||
|
||||
@@ -45,9 +45,10 @@ class SABnzbdClient extends DownloadClient {
|
||||
async getActiveDownloads() {
|
||||
try {
|
||||
// Get both queue and history to provide complete picture
|
||||
const [queueResponse, historyResponse] = await Promise.all([
|
||||
const [queueResponse, historyResponse, clientStatus] = await Promise.all([
|
||||
this.makeRequest({ mode: 'queue' }),
|
||||
this.makeRequest({ mode: 'history', limit: 10 })
|
||||
this.makeRequest({ mode: 'history', limit: 10 }),
|
||||
this.getClientStatus()
|
||||
]);
|
||||
|
||||
const queueData = queueResponse.data;
|
||||
@@ -57,15 +58,23 @@ class SABnzbdClient extends DownloadClient {
|
||||
|
||||
// Process active queue items
|
||||
if (queueData.queue && queueData.queue.slots) {
|
||||
// Find the currently downloading slot (first one with status 'Downloading')
|
||||
const activeSlot = queueData.queue.slots.find(slot => slot.status === 'Downloading');
|
||||
const activeSpeed = activeSlot && clientStatus ? (clientStatus.kbpersec ? clientStatus.kbpersec * 1024 : 0) : 0;
|
||||
|
||||
logToFile(`[SABnzbd:${this.name}] Active slot: ${activeSlot ? activeSlot.nzo_id : 'none'}, Speed: ${activeSpeed}, Client status: ${clientStatus ? JSON.stringify({ kbpersec: clientStatus.kbpersec }) : 'none'}`);
|
||||
|
||||
for (const slot of queueData.queue.slots) {
|
||||
downloads.push(this.normalizeDownload(slot, 'queue'));
|
||||
const slotSpeed = activeSlot === slot ? activeSpeed : 0;
|
||||
logToFile(`[SABnzbd:${this.name}] Slot ${slot.nzo_id} status ${slot.status}, speed ${slotSpeed}`);
|
||||
downloads.push(this.normalizeDownload(slot, 'queue', slotSpeed));
|
||||
}
|
||||
}
|
||||
|
||||
// Process recent history items (last 10)
|
||||
if (historyData.history && historyData.history.slots) {
|
||||
for (const slot of historyData.history.slots) {
|
||||
downloads.push(this.normalizeDownload(slot, 'history'));
|
||||
downloads.push(this.normalizeDownload(slot, 'history', 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,9 +111,9 @@ class SABnzbdClient extends DownloadClient {
|
||||
}
|
||||
}
|
||||
|
||||
normalizeDownload(slot, source) {
|
||||
normalizeDownload(slot, source, speed = 0) {
|
||||
const isHistory = source === 'history';
|
||||
|
||||
|
||||
// Map SABnzbd statuses to normalized status
|
||||
const statusMap = {
|
||||
'Downloading': 'Downloading',
|
||||
@@ -164,7 +173,7 @@ class SABnzbdClient extends DownloadClient {
|
||||
progress: Math.round(progress),
|
||||
size: Math.round(size),
|
||||
downloaded: Math.round(downloaded),
|
||||
speed: slot.kbpersec ? slot.kbpersec * 1024 : 0, // Convert KB/s to bytes/s
|
||||
speed: speed,
|
||||
eta: this.calculateEta(slot.timeleft || slot.eta),
|
||||
category: slot.cat || undefined,
|
||||
tags: slot.labels ? (Array.isArray(slot.labels) ? slot.labels : slot.labels.split(',')).filter(tag => tag && tag.trim()) : [],
|
||||
|
||||
@@ -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: Math.round(slot.progress * 100), mb: slot.mb, mbmissing: slot.mbleft, size: Math.round(slot.mb * 1024 * 1024), speed: Math.round((slot.kbpersec || 0) * 1024), 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: Math.round(slot.progress * 100), mb: slot.mb, mbmissing: slot.mbleft, size: Math.round(slot.mb * 1024 * 1024), speed: Math.round((slot.kbpersec || 0) * 1024), 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, mb: slot.mb, size: Math.round((slot.mb || 0) * 1024 * 1024), 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, mb: slot.mb, size: Math.round((slot.mb || 0) * 1024 * 1024), 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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
};
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user