From 1dccda529a244ce88bf2ed91977d56f875bd05cb Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 21 May 2026 20:59:06 +0100 Subject: [PATCH 01/20] feat: add Ombi requests tab and webhook panel integration - Add Ombi requests tab UI with movie/TV request display - Add showAll parameter support for Ombi requests (API and SSE) - Add Ombi webhook panel with enable/test functionality - Add Ombi webhook status endpoint with metrics - Add Ombi webhook test endpoint - Change GET /api/ombi/requests to use OmbiRetriever instead of cache - Add Ombi webhook state and API functions to frontend - Update SSE payload to include Ombi baseUrl and requests --- client/src/api.js | 47 +++++ client/src/sse.js | 10 + client/src/state.js | 3 + client/src/ui/requests.js | 150 ++++++++++++++ client/src/ui/tabs.js | 38 +++- client/src/ui/webhooks.js | 145 ++++++++++---- public/index.html | 38 ++++ public/style.css | 154 +++++++++++++++ server/app.js | 2 + server/routes/dashboard.js | 31 ++- server/routes/ombi.js | 395 +++++++++++++++++++++++++++++++++++++ server/routes/webhook.js | 203 ++++++++++++++++++- server/utils/poller.js | 24 ++- 13 files changed, 1186 insertions(+), 54 deletions(-) create mode 100644 client/src/ui/requests.js create mode 100644 server/routes/ombi.js diff --git a/client/src/api.js b/client/src/api.js index 598017d..6a94eb4 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -181,6 +181,22 @@ export async function fetchWebhookStatus() { } catch (err) { // Radarr not configured } + + // Fetch Ombi webhook status + let ombiEnabled = false; + let ombiTriggers = { requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false }; + let ombiStats = null; + try { + const ombiRes = await fetch('/api/ombi/webhook/status'); + if (ombiRes.ok) { + const ombiData = await ombiRes.json(); + ombiEnabled = ombiData.enabled || false; + ombiTriggers = ombiData.triggers || { requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false }; + ombiStats = ombiData.stats || null; + } + } catch (err) { + // Ombi not configured + } state.webhookMetrics = await metricsPromise; @@ -191,6 +207,7 @@ export async function fetchWebhookStatus() { state.sonarrWebhook = { enabled: sonarrEnabled, triggers: sonarrTriggers, stats: sonarrStats }; state.radarrWebhook = { enabled: radarrEnabled, triggers: radarrTriggers, stats: radarrStats }; + state.ombiWebhook = { enabled: ombiEnabled, triggers: ombiTriggers, stats: ombiStats }; return { success: true }; } catch (err) { @@ -279,6 +296,36 @@ export async function testRadarrWebhook() { } } +export async function enableOmbiWebhook() { + try { + const res = await fetch('/api/ombi/webhook/enable', { + method: 'POST', + headers: { 'X-CSRF-Token': state.csrfToken || '' } + }); + if (!res.ok) throw new Error('Failed to enable'); + await fetchWebhookStatus(); + return { success: true }; + } catch (err) { + console.error('Failed to enable Ombi webhook:', err); + return { success: false, error: err.message }; + } +} + +export async function testOmbiWebhook() { + try { + const res = await fetch('/api/ombi/webhook/test', { + method: 'POST', + headers: { 'X-CSRF-Token': state.csrfToken || '' } + }); + if (!res.ok) throw new Error('Test failed'); + await fetchWebhookStatus(); + return { success: true }; + } catch (err) { + console.error('Failed to test Ombi webhook:', err); + return { success: false, error: err.message }; + } +} + export async function refreshStatusPanel() { try { const res = await fetch('/api/status'); diff --git a/client/src/sse.js b/client/src/sse.js index 25b24b0..5e635bd 100644 --- a/client/src/sse.js +++ b/client/src/sse.js @@ -25,6 +25,16 @@ export function startSSE() { const filterUpdateEvent = new CustomEvent('downloadClientsUpdated'); document.dispatchEvent(filterUpdateEvent); } + // Store Ombi requests and base URL + if (data.ombiRequests) { + state.ombiRequests = data.ombiRequests; + // Trigger requests update event + const requestsUpdateEvent = new CustomEvent('ombiRequestsUpdated'); + document.dispatchEvent(requestsUpdateEvent); + } + if (data.ombiBaseUrl) { + state.ombiBaseUrl = data.ombiBaseUrl; + } document.getElementById('currentUser').textContent = state.currentUser || '-'; renderDownloads(); hideError(); diff --git a/client/src/state.js b/client/src/state.js index a3a1d5f..6895bdb 100644 --- a/client/src/state.js +++ b/client/src/state.js @@ -9,6 +9,8 @@ export const state = { isAdmin: false, showAll: false, csrfToken: null, // double-submit CSRF token, sent as X-CSRF-Token on mutating requests + ombiBaseUrl: null, // Ombi base URL for generating links + ombiRequests: null, // Ombi requests data // History section state historyDays: 7, // Default value, will be loaded from localStorage @@ -28,6 +30,7 @@ export const state = { webhookLoading: false, sonarrWebhook: { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null }, radarrWebhook: { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null }, + ombiWebhook: { enabled: false, triggers: { requestAvailable: false, requestApproved: false, requestDeclined: false, requestPending: false, requestProcessing: false }, stats: null }, webhookMetrics: null }; diff --git a/client/src/ui/requests.js b/client/src/ui/requests.js new file mode 100644 index 0000000..8d5fdb9 --- /dev/null +++ b/client/src/ui/requests.js @@ -0,0 +1,150 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. + +import { state } from '../state.js'; +import { escapeHtml } from '../utils/format.js'; + +export function renderRequests() { + const requestsList = document.getElementById('requests-list'); + const noRequests = document.getElementById('no-requests'); + + if (!requestsList) return; + + const ombiRequests = state.ombiRequests || { movie: [], tv: [] }; + const allRequests = [ + ...ombiRequests.movie.map(r => ({ ...r, mediaType: 'movie' })), + ...ombiRequests.tv.map(r => ({ ...r, mediaType: 'tv' })) + ]; + + requestsList.innerHTML = ''; + + if (allRequests.length === 0) { + if (noRequests) noRequests.style.display = 'block'; + return; + } + + if (noRequests) noRequests.style.display = 'none'; + + allRequests.forEach(request => { + const card = createRequestCard(request); + requestsList.appendChild(card); + }); +} + +function createRequestCard(request) { + const card = document.createElement('div'); + card.className = 'request-card'; + + const typeIcon = document.createElement('span'); + typeIcon.className = `request-type-icon ${request.mediaType}`; + typeIcon.textContent = request.mediaType === 'movie' ? '🎬' : '📺'; + + const content = document.createElement('div'); + content.className = 'request-content'; + + const title = document.createElement('div'); + title.className = 'request-title'; + title.textContent = request.title || 'Unknown Title'; + + const meta = document.createElement('div'); + meta.className = 'request-meta'; + + const statusBadge = createStatusBadge(request); + meta.appendChild(statusBadge); + + if (request.year) { + const year = document.createElement('span'); + year.className = 'request-year'; + year.textContent = request.year; + meta.appendChild(year); + } + + if (request.requestedUser || request.requestedByAlias) { + const user = document.createElement('span'); + user.className = 'request-user'; + user.textContent = `Requested by: ${request.requestedUser || request.requestedByAlias}`; + meta.appendChild(user); + } + + if (request.quality) { + const quality = document.createElement('span'); + quality.className = 'request-quality'; + quality.textContent = request.quality; + meta.appendChild(quality); + } + + content.appendChild(title); + content.appendChild(meta); + + const actions = document.createElement('div'); + actions.className = 'request-actions'; + + if (request.theMovieDbId) { + const ombiLink = document.createElement('a'); + ombiLink.className = 'request-link ombi-link'; + ombiLink.href = `${state.ombiBaseUrl}/details/${request.mediaType}/${request.theMovieDbId}`; + ombiLink.target = '_blank'; + ombiLink.title = 'View in Ombi'; + + const ombiIcon = document.createElement('img'); + ombiIcon.src = '/images/ombi.svg'; + ombiIcon.alt = 'Ombi'; + ombiIcon.className = 'request-icon'; + + ombiLink.appendChild(ombiIcon); + actions.appendChild(ombiLink); + } + + card.appendChild(typeIcon); + card.appendChild(content); + card.appendChild(actions); + + return card; +} + +function createStatusBadge(request) { + const badge = document.createElement('span'); + badge.className = 'request-status-badge'; + + let status = 'unknown'; + let text = 'Unknown'; + + if (request.available) { + status = 'available'; + text = 'Available'; + } else if (request.approved) { + status = 'approved'; + text = 'Approved'; + } else if (request.denied) { + status = 'denied'; + text = `Denied: ${request.deniedReason || 'No reason'}`; + } else if (request.requested) { + status = 'pending'; + text = 'Pending'; + } + + badge.classList.add(status); + badge.textContent = text; + + return badge; +} + +export function setupRequestsTab() { + // Listen for SSE updates + if (state.sseSource) { + state.sseSource.addEventListener('message', (event) => { + const data = JSON.parse(event.data); + if (data.ombiRequests) { + state.ombiRequests = data.ombiRequests; + renderRequests(); + } + }); + } + + // Also listen for custom event triggered from sse.js + document.addEventListener('ombiRequestsUpdated', () => { + renderRequests(); + }); + + // Initial render + renderRequests(); +} diff --git a/client/src/ui/tabs.js b/client/src/ui/tabs.js index 94d1e35..aa62125 100644 --- a/client/src/ui/tabs.js +++ b/client/src/ui/tabs.js @@ -2,42 +2,62 @@ import { getActiveTab, saveActiveTab } from '../utils/storage.js'; import { loadHistory } from './history.js'; +import { setupRequestsTab } from './requests.js'; export function initTabs() { const downloadsTab = document.querySelector('[data-tab="downloads"]'); + const requestsTab = document.querySelector('[data-tab="requests"]'); const historyTab = document.querySelector('[data-tab="history"]'); if (!downloadsTab || !historyTab) return; // Load saved tab const savedTab = getActiveTab(); - if (savedTab === 'history') { + if (savedTab === 'requests') { + activateTab('requests'); + } else if (savedTab === 'history') { activateTab('history'); } else { activateTab('downloads'); } downloadsTab.addEventListener('click', () => activateTab('downloads')); + if (requestsTab) { + requestsTab.addEventListener('click', () => activateTab('requests')); + } historyTab.addEventListener('click', () => activateTab('history')); } export function activateTab(tab) { const downloadsTab = document.querySelector('[data-tab="downloads"]'); + const requestsTab = document.querySelector('[data-tab="requests"]'); const historyTab = document.querySelector('[data-tab="history"]'); const downloadsSection = document.getElementById('tab-downloads'); + const requestsSection = document.getElementById('tab-requests'); const historySection = document.getElementById('tab-history'); + // Remove active class from all tabs + if (downloadsTab) downloadsTab.classList.remove('active'); + if (requestsTab) requestsTab.classList.remove('active'); + if (historyTab) historyTab.classList.remove('active'); + + // Hide all sections + if (downloadsSection) downloadsSection.classList.add('hidden'); + if (requestsSection) requestsSection.classList.add('hidden'); + if (historySection) historySection.classList.add('hidden'); + if (tab === 'downloads') { - downloadsTab.classList.add('active'); - historyTab.classList.remove('active'); - downloadsSection.classList.remove('hidden'); - historySection.classList.add('hidden'); + if (downloadsTab) downloadsTab.classList.add('active'); + if (downloadsSection) downloadsSection.classList.remove('hidden'); saveActiveTab('downloads'); + } else if (tab === 'requests') { + if (requestsTab) requestsTab.classList.add('active'); + if (requestsSection) requestsSection.classList.remove('hidden'); + saveActiveTab('requests'); + setupRequestsTab(); } else if (tab === 'history') { - historyTab.classList.add('active'); - downloadsTab.classList.remove('active'); - historySection.classList.remove('hidden'); - downloadsSection.classList.add('hidden'); + if (historyTab) historyTab.classList.add('active'); + if (historySection) historySection.classList.remove('hidden'); saveActiveTab('history'); loadHistory(); } diff --git a/client/src/ui/webhooks.js b/client/src/ui/webhooks.js index 55f720e..9da0a12 100644 --- a/client/src/ui/webhooks.js +++ b/client/src/ui/webhooks.js @@ -1,7 +1,7 @@ // Copyright (c) 2026 Gordon Bolton. MIT License. import { state } from '../state.js'; -import { fetchWebhookStatus as apiFetchWebhookStatus, enableSonarrWebhook as apiEnableSonarrWebhook, enableRadarrWebhook as apiEnableRadarrWebhook, testSonarrWebhook as apiTestSonarrWebhook, testRadarrWebhook as apiTestRadarrWebhook } from '../api.js'; +import { fetchWebhookStatus as apiFetchWebhookStatus, enableSonarrWebhook as apiEnableSonarrWebhook, enableRadarrWebhook as apiEnableRadarrWebhook, enableOmbiWebhook as apiEnableOmbiWebhook, testSonarrWebhook as apiTestSonarrWebhook, testRadarrWebhook as apiTestRadarrWebhook, testOmbiWebhook as apiTestOmbiWebhook } from '../api.js'; import { formatTimeAgo } from '../utils/format.js'; export function initWebhooks() { @@ -13,8 +13,10 @@ export function initWebhooks() { document.getElementById('webhooks-header').addEventListener('click', toggleWebhookSection); document.getElementById('enable-sonarr-webhook').addEventListener('click', enableSonarrWebhook); document.getElementById('enable-radarr-webhook').addEventListener('click', enableRadarrWebhook); + document.getElementById('enable-ombi-webhook').addEventListener('click', enableOmbiWebhook); document.getElementById('test-sonarr-webhook').addEventListener('click', testSonarrWebhook); document.getElementById('test-radarr-webhook').addEventListener('click', testRadarrWebhook); + document.getElementById('test-ombi-webhook').addEventListener('click', testOmbiWebhook); } export function toggleWebhookSection() { @@ -58,9 +60,9 @@ export function renderWebhookStatus() { const sonarrTriggers = document.getElementById('sonarr-triggers'); const sonarrStats = document.getElementById('sonarr-stats'); - sonarrStatus.textContent = sonarrWebhook.enabled ? '● Enabled' : '○ Disabled'; - sonarrStatus.className = 'status-indicator ' + (sonarrWebhook.enabled ? 'enabled' : 'disabled'); - if (sonarrWebhook.enabled) { + sonarrStatus.textContent = state.sonarrWebhook.enabled ? '● Enabled' : '○ Disabled'; + sonarrStatus.className = 'status-indicator ' + (state.sonarrWebhook.enabled ? 'enabled' : 'disabled'); + if (state.sonarrWebhook.enabled) { sonarrEnableBtn.classList.add('hidden'); sonarrTestBtn.classList.remove('hidden'); sonarrTriggers.classList.remove('hidden'); @@ -70,22 +72,22 @@ export function renderWebhookStatus() { sonarrTriggers.classList.add('hidden'); } - if (sonarrWebhook.enabled) { - document.getElementById('sonarr-onGrab').textContent = sonarrWebhook.triggers.onGrab ? '✓' : '✗'; - document.getElementById('sonarr-onGrab').className = 'trigger-value ' + (sonarrWebhook.triggers.onGrab ? 'active' : 'inactive'); - document.getElementById('sonarr-onDownload').textContent = sonarrWebhook.triggers.onDownload ? '✓' : '✗'; - document.getElementById('sonarr-onDownload').className = 'trigger-value ' + (sonarrWebhook.triggers.onDownload ? 'active' : 'inactive'); - document.getElementById('sonarr-onImport').textContent = sonarrWebhook.triggers.onImport ? '✓' : '✗'; - document.getElementById('sonarr-onImport').className = 'trigger-value ' + (sonarrWebhook.triggers.onImport ? 'active' : 'inactive'); - document.getElementById('sonarr-onUpgrade').textContent = sonarrWebhook.triggers.onUpgrade ? '✓' : '✗'; - document.getElementById('sonarr-onUpgrade').className = 'trigger-value ' + (sonarrWebhook.triggers.onUpgrade ? 'active' : 'inactive'); + if (state.sonarrWebhook.enabled) { + document.getElementById('sonarr-onGrab').textContent = state.sonarrWebhook.triggers.onGrab ? '✓' : '✗'; + document.getElementById('sonarr-onGrab').className = 'trigger-value ' + (state.sonarrWebhook.triggers.onGrab ? 'active' : 'inactive'); + document.getElementById('sonarr-onDownload').textContent = state.sonarrWebhook.triggers.onDownload ? '✓' : '✗'; + document.getElementById('sonarr-onDownload').className = 'trigger-value ' + (state.sonarrWebhook.triggers.onDownload ? 'active' : 'inactive'); + document.getElementById('sonarr-onImport').textContent = state.sonarrWebhook.triggers.onImport ? '✓' : '✗'; + document.getElementById('sonarr-onImport').className = 'trigger-value ' + (state.sonarrWebhook.triggers.onImport ? 'active' : 'inactive'); + document.getElementById('sonarr-onUpgrade').textContent = state.sonarrWebhook.triggers.onUpgrade ? '✓' : '✗'; + document.getElementById('sonarr-onUpgrade').className = 'trigger-value ' + (state.sonarrWebhook.triggers.onUpgrade ? 'active' : 'inactive'); } - if (sonarrWebhook.stats) { + if (state.sonarrWebhook.stats) { sonarrStats.classList.remove('hidden'); - document.getElementById('sonarr-events').textContent = sonarrWebhook.stats.eventsReceived ?? 0; - document.getElementById('sonarr-polls').textContent = sonarrWebhook.stats.pollsSkipped ?? 0; - document.getElementById('sonarr-last').textContent = formatTimeAgo(sonarrWebhook.stats.lastWebhookTimestamp); + document.getElementById('sonarr-events').textContent = state.sonarrWebhook.stats.eventsReceived ?? 0; + document.getElementById('sonarr-polls').textContent = state.sonarrWebhook.stats.pollsSkipped ?? 0; + document.getElementById('sonarr-last').textContent = formatTimeAgo(state.sonarrWebhook.stats.lastWebhookTimestamp); } else { sonarrStats.classList.add('hidden'); } @@ -97,9 +99,9 @@ export function renderWebhookStatus() { const radarrTriggers = document.getElementById('radarr-triggers'); const radarrStats = document.getElementById('radarr-stats'); - radarrStatus.textContent = radarrWebhook.enabled ? '● Enabled' : '○ Disabled'; - radarrStatus.className = 'status-indicator ' + (radarrWebhook.enabled ? 'enabled' : 'disabled'); - if (radarrWebhook.enabled) { + radarrStatus.textContent = state.radarrWebhook.enabled ? '● Enabled' : '○ Disabled'; + radarrStatus.className = 'status-indicator ' + (state.radarrWebhook.enabled ? 'enabled' : 'disabled'); + if (state.radarrWebhook.enabled) { radarrEnableBtn.classList.add('hidden'); radarrTestBtn.classList.remove('hidden'); radarrTriggers.classList.remove('hidden'); @@ -109,25 +111,66 @@ export function renderWebhookStatus() { radarrTriggers.classList.add('hidden'); } - if (radarrWebhook.enabled) { - document.getElementById('radarr-onGrab').textContent = radarrWebhook.triggers.onGrab ? '✓' : '✗'; - document.getElementById('radarr-onGrab').className = 'trigger-value ' + (radarrWebhook.triggers.onGrab ? 'active' : 'inactive'); - document.getElementById('radarr-onDownload').textContent = radarrWebhook.triggers.onDownload ? '✓' : '✗'; - document.getElementById('radarr-onDownload').className = 'trigger-value ' + (radarrWebhook.triggers.onDownload ? 'active' : 'inactive'); - document.getElementById('radarr-onImport').textContent = radarrWebhook.triggers.onImport ? '✓' : '✗'; - document.getElementById('radarr-onImport').className = 'trigger-value ' + (radarrWebhook.triggers.onImport ? 'active' : 'inactive'); - document.getElementById('radarr-onUpgrade').textContent = radarrWebhook.triggers.onUpgrade ? '✓' : '✗'; - document.getElementById('radarr-onUpgrade').className = 'trigger-value ' + (radarrWebhook.triggers.onUpgrade ? 'active' : 'inactive'); + if (state.radarrWebhook.enabled) { + document.getElementById('radarr-onGrab').textContent = state.radarrWebhook.triggers.onGrab ? '✓' : '✗'; + document.getElementById('radarr-onGrab').className = 'trigger-value ' + (state.radarrWebhook.triggers.onGrab ? 'active' : 'inactive'); + document.getElementById('radarr-onDownload').textContent = state.radarrWebhook.triggers.onDownload ? '✓' : '✗'; + document.getElementById('radarr-onDownload').className = 'trigger-value ' + (state.radarrWebhook.triggers.onDownload ? 'active' : 'inactive'); + document.getElementById('radarr-onImport').textContent = state.radarrWebhook.triggers.onImport ? '✓' : '✗'; + document.getElementById('radarr-onImport').className = 'trigger-value ' + (state.radarrWebhook.triggers.onImport ? 'active' : 'inactive'); + document.getElementById('radarr-onUpgrade').textContent = state.radarrWebhook.triggers.onUpgrade ? '✓' : '✗'; + document.getElementById('radarr-onUpgrade').className = 'trigger-value ' + (state.radarrWebhook.triggers.onUpgrade ? 'active' : 'inactive'); } - if (radarrWebhook.stats) { + if (state.radarrWebhook.stats) { radarrStats.classList.remove('hidden'); - document.getElementById('radarr-events').textContent = radarrWebhook.stats.eventsReceived ?? 0; - document.getElementById('radarr-polls').textContent = radarrWebhook.stats.pollsSkipped ?? 0; - document.getElementById('radarr-last').textContent = formatTimeAgo(radarrWebhook.stats.lastWebhookTimestamp); + document.getElementById('radarr-events').textContent = state.radarrWebhook.stats.eventsReceived ?? 0; + document.getElementById('radarr-polls').textContent = state.radarrWebhook.stats.pollsSkipped ?? 0; + document.getElementById('radarr-last').textContent = formatTimeAgo(state.radarrWebhook.stats.lastWebhookTimestamp); } else { radarrStats.classList.add('hidden'); } + + // Ombi + const ombiStatus = document.getElementById('ombi-status'); + const ombiEnableBtn = document.getElementById('enable-ombi-webhook'); + const ombiTestBtn = document.getElementById('test-ombi-webhook'); + const ombiTriggers = document.getElementById('ombi-triggers'); + const ombiStats = document.getElementById('ombi-stats'); + + ombiStatus.textContent = state.ombiWebhook.enabled ? '● Enabled' : '○ Disabled'; + ombiStatus.className = 'status-indicator ' + (state.ombiWebhook.enabled ? 'enabled' : 'disabled'); + if (state.ombiWebhook.enabled) { + ombiEnableBtn.classList.add('hidden'); + ombiTestBtn.classList.remove('hidden'); + ombiTriggers.classList.remove('hidden'); + } else { + ombiEnableBtn.classList.remove('hidden'); + ombiTestBtn.classList.add('hidden'); + ombiTriggers.classList.add('hidden'); + } + + if (state.ombiWebhook.enabled) { + document.getElementById('ombi-requestAvailable').textContent = state.ombiWebhook.triggers.requestAvailable ? '✓' : '✗'; + document.getElementById('ombi-requestAvailable').className = 'trigger-value ' + (state.ombiWebhook.triggers.requestAvailable ? 'active' : 'inactive'); + document.getElementById('ombi-requestApproved').textContent = state.ombiWebhook.triggers.requestApproved ? '✓' : '✗'; + document.getElementById('ombi-requestApproved').className = 'trigger-value ' + (state.ombiWebhook.triggers.requestApproved ? 'active' : 'inactive'); + document.getElementById('ombi-requestDeclined').textContent = state.ombiWebhook.triggers.requestDeclined ? '✓' : '✗'; + document.getElementById('ombi-requestDeclined').className = 'trigger-value ' + (state.ombiWebhook.triggers.requestDeclined ? 'active' : 'inactive'); + document.getElementById('ombi-requestPending').textContent = state.ombiWebhook.triggers.requestPending ? '✓' : '✗'; + document.getElementById('ombi-requestPending').className = 'trigger-value ' + (state.ombiWebhook.triggers.requestPending ? 'active' : 'inactive'); + document.getElementById('ombi-requestProcessing').textContent = state.ombiWebhook.triggers.requestProcessing ? '✓' : '✗'; + document.getElementById('ombi-requestProcessing').className = 'trigger-value ' + (state.ombiWebhook.triggers.requestProcessing ? 'active' : 'inactive'); + } + + if (state.ombiWebhook.stats) { + ombiStats.classList.remove('hidden'); + document.getElementById('ombi-events').textContent = state.ombiWebhook.stats.eventsReceived ?? 0; + document.getElementById('ombi-polls').textContent = state.ombiWebhook.stats.pollsSkipped ?? 0; + document.getElementById('ombi-last').textContent = formatTimeAgo(state.ombiWebhook.stats.lastWebhookTimestamp); + } else { + ombiStats.classList.add('hidden'); + } } export async function enableSonarrWebhook() { @@ -198,12 +241,48 @@ export async function testRadarrWebhook() { } } +export async function enableOmbiWebhook() { + setWebhookLoading(true); + try { + const result = await apiEnableOmbiWebhook(); + if (!result.success) { + console.error('Failed to enable Ombi webhook:', result.error); + alert('Failed to enable Ombi webhook. Check console for details.'); + } + } catch (err) { + console.error('Failed to enable Ombi webhook:', err); + alert('Failed to enable Ombi webhook. Check console for details.'); + } finally { + setWebhookLoading(false); + } +} + +export async function testOmbiWebhook() { + setWebhookLoading(true); + try { + const result = await apiTestOmbiWebhook(); + if (result.success) { + alert('Ombi webhook test sent successfully!'); + } else { + console.error('Failed to test Ombi webhook:', result.error); + alert('Failed to test Ombi webhook. Check console for details.'); + } + } catch (err) { + console.error('Failed to test Ombi webhook:', err); + alert('Failed to test Ombi webhook. Check console for details.'); + } finally { + setWebhookLoading(false); + } +} + export function setWebhookLoading(loading) { state.webhookLoading = loading; document.getElementById('enable-sonarr-webhook').disabled = loading; document.getElementById('enable-radarr-webhook').disabled = loading; + document.getElementById('enable-ombi-webhook').disabled = loading; document.getElementById('test-sonarr-webhook').disabled = loading; document.getElementById('test-radarr-webhook').disabled = loading; + document.getElementById('test-ombi-webhook').disabled = loading; const loadingEl = document.getElementById('webhook-loading'); if (loading) { loadingEl.classList.remove('hidden'); diff --git a/public/index.html b/public/index.html index 7356847..4939009 100644 --- a/public/index.html +++ b/public/index.html @@ -129,6 +129,31 @@ + + +
+

Ombi

+
+ ○ Disabled + + +
+ + +
@@ -139,6 +164,7 @@
+
@@ -172,6 +198,18 @@
+ + "}d+=`
Cache (${e.cache.entryCount} entries, ${m} KB)
- `;for(const h of e.cache.entries){const k=h.sizeBytes>1024?(h.sizeBytes/1024).toFixed(1)+" KB":h.sizeBytes+" B",E=h.expired?'expired':(h.ttlRemainingMs/1e3).toFixed(0)+"s",S=h.itemCount!==null?h.itemCount:"—";u+=``}u+="
KeyItemsSizeTTL
${D(h.key)}${S}${k}${E}
";const g=document.getElementById("status-content"),y=document.getElementById("status-panel");console.log("[Status] contentDiv found:",!!g,"panel children:",(q=y==null?void 0:y.children)==null?void 0:q.length,"HTML length:",u.length),y&&console.log("[Status] panel innerHTML preview:",y.innerHTML.substring(0,200)),g?(g.innerHTML=u,console.log("[Status] HTML rendered, contentDiv innerHTML length:",g.innerHTML.length)):console.error("[Status] contentDiv not found!");const B=document.getElementById("status-close-btn");B&&B.addEventListener("click",Ke),t.querySelectorAll(".timing-bar[data-w]").forEach(h=>{h.style.width=h.dataset.w+"%"})}function D(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}function Qe(){return new Promise(e=>{const t=document.getElementById("login-container");t.classList.add("fade-out"),t.addEventListener("transitionend",()=>{t.classList.add("hidden"),t.classList.remove("fade-out"),e()},{once:!0})})}function Ve(){const e=document.getElementById("splash-screen");e.classList.remove("hidden"),e.style.opacity="1",e.classList.remove("fade-out")}function M(e){return new Promise(t=>{const n=Date.now()-(e||0),s=Math.max(0,le-n);setTimeout(()=>{const a=document.getElementById("splash-screen");a.classList.add("fade-out");const c=setTimeout(()=>{a.classList.add("hidden"),t()},400+100);a.addEventListener("transitionend",()=>{clearTimeout(c),a.classList.add("hidden"),t()},{once:!0})},s)})}async function Ye(){const e=Date.now();try{(await ue()).authenticated?(ne(),se(),F(),await M(e)):(await M(e),A())}catch(t){console.error("Authentication check failed:",t),await M(e),A()}}async function Ze(e){e.preventDefault();const t=document.getElementById("username").value,n=document.getElementById("password").value,s=document.getElementById("remember-me").checked;try{const a=await me(t,n,s);if(a.success){await Qe(),Ve(),await new Promise(c=>requestAnimationFrame(()=>requestAnimationFrame(c))),ne(),se();const l=Date.now();F(),await M(l)}else z(a.error||"Login failed")}catch(a){z("Login failed. Please try again."),console.error(a)}}async function et(){try{V(),Z(),statusRefreshHandle&&(clearInterval(statusRefreshHandle),statusRefreshHandle.value=null),await he(),currentUser=null,We(),A()}catch(e){console.error("Logout failed:",e)}}function A(){document.getElementById("login-container").classList.remove("hidden"),document.getElementById("dashboard-container").classList.add("hidden"),tt()}function ne(){document.getElementById("login-container").classList.add("hidden"),document.getElementById("dashboard-container").classList.remove("hidden"),document.getElementById("currentUser").textContent=o.currentUser.name||"-",document.getElementById("status-panel").classList.add("hidden");const t=document.getElementById("webhooks-section");t&&t.classList.add("hidden");const n=document.getElementById("admin-controls");o.isAdmin?n.classList.remove("hidden"):n.classList.add("hidden");const s=document.getElementById("history-days");s&&(s.value=o.historyDays),He()}function z(e){const t=document.getElementById("login-error");t.textContent=e,t.classList.remove("hidden")}function tt(){document.getElementById("login-error").classList.add("hidden")}function nt(){document.getElementById("error-message").classList.add("hidden")}function se(){document.getElementById("loading").classList.remove("hidden")}function st(){document.getElementById("loading").classList.add("hidden")}function at(){const e=document.getElementById("download-client-filter-btn"),t=document.getElementById("download-client-filter-dropdown"),n=document.getElementById("download-client-filter-close");!e||!t||(e.addEventListener("click",s=>{s.stopPropagation(),t.classList.toggle("open")}),n.addEventListener("click",()=>{t.classList.remove("open")}),document.addEventListener("click",s=>{!t.contains(s.target)&&s.target!==e&&t.classList.remove("open")}),document.addEventListener("downloadClientsUpdated",J),J())}function J(){const e=document.getElementById("download-client-filter-list");e&&(e.innerHTML="",o.downloadClients.forEach((t,n)=>{const s=document.createElement("div");s.className="filter-item",s.dataset.index=n;const a=document.createElement("input");a.type="checkbox",a.id=`client-${n}`,a.checked=o.selectedDownloadClients.includes(n),a.addEventListener("change",()=>ot(n));const l=document.createElement("label");l.htmlFor=`client-${n}`,l.textContent=t.name||`${t.type} (${t.id})`,s.appendChild(a),s.appendChild(l),e.appendChild(s)}),ae())}function ot(e){const t=o.selectedDownloadClients.indexOf(e);t>-1?o.selectedDownloadClients.splice(t,1):o.selectedDownloadClients.push(e),Ae(o.selectedDownloadClients),ae(),Q()}function ae(){const e=document.getElementById("download-client-filter-count");e&&(o.selectedDownloadClients.length===0?e.textContent="All":e.textContent=o.selectedDownloadClients.length)}(function(){const t=Y();t&&document.documentElement.setAttribute("data-theme",t)})();function rt(){const e=document.getElementById("theme-toggle");e&&e.addEventListener("click",()=>{const n=Y()==="dark"?"light":"dark";it(n)})}function it(e){document.documentElement.setAttribute("data-theme",e),Re(e)}function lt(){const e=document.getElementById("downloads-tab"),t=document.getElementById("history-tab");if(document.getElementById("downloads-section"),document.getElementById("history-section"),!e||!t)return;const n=Fe();T(n==="history"?"history":"downloads"),e.addEventListener("click",()=>T("downloads")),t.addEventListener("click",()=>T("history"))}function T(e){const t=document.getElementById("downloads-tab"),n=document.getElementById("history-tab"),s=document.getElementById("tab-downloads"),a=document.getElementById("tab-history");e==="downloads"?(t.classList.add("active"),n.classList.remove("active"),s.classList.remove("hidden"),a.classList.add("hidden"),j("downloads")):e==="history"&&(n.classList.add("active"),t.classList.remove("active"),a.classList.remove("hidden"),s.classList.add("hidden"),j("history"),x())}function ct(){T("downloads")}document.addEventListener("DOMContentLoaded",()=>{const e=document.getElementById("login-form");e&&e.addEventListener("submit",Ze);const t=document.getElementById("logout-btn");t&&t.addEventListener("click",et);const n=document.getElementById("show-all-toggle");n&&n.addEventListener("change",l=>Ne(l.target.checked));const s=document.getElementById("status-toggle");s&&s.addEventListener("click",Je);const a=document.getElementById("home-btn");a&&a.addEventListener("click",ct),rt(),lt(),at(),$e(),Pe(),ge().then(l=>{const c=document.getElementById("app-version");c&&l&&(c.textContent="v"+l)}),Ye()}); + `;for(const g of e.cache.entries){const w=g.sizeBytes>1024?(g.sizeBytes/1024).toFixed(1)+" KB":g.sizeBytes+" B",S=g.expired?'expired':(g.ttlRemainingMs/1e3).toFixed(0)+"s",C=g.itemCount!==null?g.itemCount:"—";d+=`${N(g.key)}${C}${w}${S}`}d+="";const h=document.getElementById("status-content"),y=document.getElementById("status-panel");console.log("[Status] contentDiv found:",!!h,"panel children:",(G=y==null?void 0:y.children)==null?void 0:G.length,"HTML length:",d.length),y&&console.log("[Status] panel innerHTML preview:",y.innerHTML.substring(0,200)),h?(h.innerHTML=d,console.log("[Status] HTML rendered, contentDiv innerHTML length:",h.innerHTML.length)):console.error("[Status] contentDiv not found!");const b=document.getElementById("status-close-btn");b&&b.addEventListener("click",dt),t.querySelectorAll(".timing-bar[data-w]").forEach(g=>{g.style.width=g.dataset.w+"%"})}function N(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}function mt(){return new Promise(e=>{const t=document.getElementById("login-container");t.classList.add("fade-out"),t.addEventListener("transitionend",()=>{t.classList.add("hidden"),t.classList.remove("fade-out"),e()},{once:!0})})}function ht(){const e=document.getElementById("splash-screen");e.classList.remove("hidden"),e.style.opacity="1",e.classList.remove("fade-out")}function R(e){return new Promise(t=>{const s=Date.now()-(e||0),o=Math.max(0,ve-s);setTimeout(()=>{const a=document.getElementById("splash-screen");a.classList.add("fade-out");const c=setTimeout(()=>{a.classList.add("hidden"),t()},400+100);a.addEventListener("transitionend",()=>{clearTimeout(c),a.classList.add("hidden"),t()},{once:!0})},o)})}async function pt(){const e=Date.now();try{(await Se()).authenticated?(me(),he(),$(),await R(e)):(await R(e),F())}catch(t){console.error("Authentication check failed:",t),await R(e),F()}}async function ft(e){e.preventDefault();const t=document.getElementById("username").value,s=document.getElementById("password").value,o=document.getElementById("remember-me").checked;try{const a=await we(t,s,o);if(a.success){await mt(),ht(),await new Promise(c=>requestAnimationFrame(()=>requestAnimationFrame(c))),me(),he();const r=Date.now();$(),await R(r)}else V(a.error||"Login failed")}catch(a){V("Login failed. Please try again."),console.error(a)}}async function gt(){try{de(),le(),n.statusRefreshHandle&&(clearInterval(n.statusRefreshHandle),n.statusRefreshHandle=null),await Ce(),n.currentUser=null,Ve(),F()}catch(e){console.error("Logout failed:",e)}}function F(){document.getElementById("login-container").classList.remove("hidden"),document.getElementById("dashboard-container").classList.add("hidden"),bt()}function me(){document.getElementById("login-container").classList.add("hidden"),document.getElementById("dashboard-container").classList.remove("hidden"),document.getElementById("currentUser").textContent=n.currentUser.name||"-",document.getElementById("status-panel").classList.add("hidden");const t=document.getElementById("webhooks-section");t&&t.classList.add("hidden");const s=document.getElementById("admin-controls");n.isAdmin?s.classList.remove("hidden"):s.classList.add("hidden");const o=document.getElementById("history-days");o&&(o.value=n.historyDays),Xe()}function V(e){const t=document.getElementById("login-error");t.textContent=e,t.classList.remove("hidden")}function bt(){document.getElementById("login-error").classList.add("hidden")}function yt(){document.getElementById("error-message").classList.add("hidden")}function he(){document.getElementById("loading").classList.remove("hidden")}function vt(){document.getElementById("loading").classList.add("hidden")}function Et(){const e=document.getElementById("download-client-filter-btn"),t=document.getElementById("download-client-filter-dropdown"),s=document.getElementById("download-client-filter-close");!e||!t||(e.addEventListener("click",o=>{o.stopPropagation(),t.classList.toggle("open")}),s.addEventListener("click",()=>{t.classList.remove("open")}),document.addEventListener("click",o=>{!t.contains(o.target)&&o.target!==e&&t.classList.remove("open")}),document.addEventListener("downloadClientsUpdated",Y),Y())}function Y(){const e=document.getElementById("download-client-filter-list");e&&(e.innerHTML="",n.downloadClients.forEach((t,s)=>{const o=document.createElement("div");o.className="filter-item",o.dataset.index=s;const a=document.createElement("input");a.type="checkbox",a.id=`client-${s}`,a.checked=n.selectedDownloadClients.includes(s),a.addEventListener("change",()=>kt(s));const r=document.createElement("label");r.htmlFor=`client-${s}`,r.textContent=t.name||`${t.type} (${t.id})`,o.appendChild(a),o.appendChild(r),e.appendChild(o)}),pe())}function kt(e){const t=n.selectedDownloadClients.indexOf(e);t>-1?n.selectedDownloadClients.splice(t,1):n.selectedDownloadClients.push(e),_e(n.selectedDownloadClients),pe(),oe()}function pe(){const e=document.getElementById("download-client-filter-count");e&&(n.selectedDownloadClients.length===0?e.textContent="All":e.textContent=n.selectedDownloadClients.length)}function fe(e){return e?e.available?"available":e.denied?"denied":e.approved?"approved":e.requested?"pending":"unknown":"unknown"}function St(e,t){if(!t||t.length===0)return e;const s=t.map(o=>o.toLowerCase());return s.includes("all")?e:e.filter(o=>s.includes(o.mediaType))}function wt(e,t){if(!t||t.length===0)return e;const s=t.map(o=>o.toLowerCase());return e.filter(o=>s.includes(fe(o)))}function Ct(e,t){if(!t||t.trim()==="")return e;const s=t.trim().toLowerCase();return e.filter(o=>(o.title||"").toLowerCase().includes(s))}function It(e,t){const s=[...e];switch(t){case"requestedDate_asc":return s.sort((o,a)=>{const r=o.requestedDate?new Date(o.requestedDate).getTime():0,c=a.requestedDate?new Date(a.requestedDate).getTime():0;return r-c});case"title_asc":return s.sort((o,a)=>(o.title||"").localeCompare(a.title||""));case"title_desc":return s.sort((o,a)=>(a.title||"").localeCompare(o.title||""));case"requestedDate_desc":default:return s.sort((o,a)=>{const r=o.requestedDate?new Date(o.requestedDate).getTime():0;return(a.requestedDate?new Date(a.requestedDate).getTime():0)-r})}}function Bt(e,{types:t,statuses:s,sort:o,search:a}={}){let r=[...e];return r=St(r,t),r=wt(r,s),r=Ct(r,a),r=It(r,o),r}function Lt(e){return e?e.requestedUser&&typeof e.requestedUser=="object"?e.requestedUser.alias||e.requestedUser.userAlias||e.requestedUser.userName||e.requestedUser.normalizedUserName||"":e.requestedUser||e.requestedByAlias||"":""}function q(){const e=document.getElementById("requests-list"),t=document.getElementById("no-requests");if(!e)return;const s=n.ombiRequests||{movie:[],tv:[]},o=[...s.movie.map(r=>({...r,mediaType:"movie"})),...s.tv.map(r=>({...r,mediaType:"tv"}))],a=Bt(o,{types:n.selectedRequestTypes,statuses:n.selectedRequestStatuses,sort:n.requestSortMode,search:n.requestSearchQuery});if(e.innerHTML="",a.length===0){if(t){t.style.display="block";const r=t.querySelector("p");if(r){const c=o.length>0;r.textContent=c?"No requests match your filters.":"No requests found."}}return}t&&(t.style.display="none"),a.forEach(r=>{const c=qt(r);e.appendChild(c)})}function qt(e){const t=document.createElement("div");t.className="request-card";const s=document.createElement("span");s.className=`request-type-icon ${e.mediaType}`,s.textContent=e.mediaType==="movie"?"🎬":"📺";const o=document.createElement("div");o.className="request-content";const a=document.createElement("div");a.className="request-title",a.textContent=e.title||"Unknown Title";const r=document.createElement("div");r.className="request-meta";const c=Tt(e);if(r.appendChild(c),e.year){const l=document.createElement("span");l.className="request-year",l.textContent=e.year,r.appendChild(l)}const m=Lt(e);if(m){const l=document.createElement("span");l.className="request-user",l.textContent=`Requested by: ${m}`,r.appendChild(l)}if(e.quality){const l=document.createElement("span");l.className="request-quality",l.textContent=e.quality,r.appendChild(l)}o.appendChild(a),o.appendChild(r);const d=document.createElement("div");if(d.className="request-actions",n.ombiBaseUrl&&e.theMovieDbId){const l=document.createElement("a");l.className="request-link ombi-link",l.href=`${n.ombiBaseUrl}/details/${e.mediaType}/${e.theMovieDbId}`,l.target="_blank",l.title="View in Ombi";const f=document.createElement("img");f.src="/images/ombi.svg",f.alt="Ombi",f.className="request-icon",l.appendChild(f),d.appendChild(l)}return t.appendChild(s),t.appendChild(o),t.appendChild(d),t}function Tt(e){const t=document.createElement("span");t.className="request-status-badge";const s=fe(e),o={available:"Available",denied:`Denied: ${e.deniedReason||"No reason"}`,approved:"Approved",pending:"Pending",unknown:"Unknown"};return t.classList.add(s),t.textContent=o[s]||"Unknown",t}function xt(){const e=document.getElementById("request-type-filter-btn"),t=document.getElementById("request-type-filter-dropdown"),s=document.getElementById("request-type-select-all"),o=document.getElementById("request-type-deselect-all");if(!e||!t)return;e.addEventListener("click",r=>{r.stopPropagation(),t.classList.toggle("open")}),s==null||s.addEventListener("click",()=>Z(!0)),o==null||o.addEventListener("click",()=>Z(!1)),t.querySelectorAll(".request-filter-checkbox").forEach(r=>{r.addEventListener("change",()=>{const c=r.closest(".request-filter-option").dataset.value;Nt(c,r.checked)})}),W()}function Z(e){const s=document.getElementById("request-type-filter-dropdown").querySelectorAll(".request-filter-checkbox"),o=[];s.forEach(a=>{a.checked=e,e&&o.push(a.closest(".request-filter-option").dataset.value)}),n.selectedRequestTypes=e?o:[],re(n.selectedRequestTypes),W(),q()}function Nt(e,t){const s=n.selectedRequestTypes.indexOf(e);t&&s===-1?n.selectedRequestTypes.push(e):!t&&s>-1&&n.selectedRequestTypes.splice(s,1),re(n.selectedRequestTypes),W(),q()}function W(){const e=document.getElementById("request-type-selected-text");if(!e)return;const s=document.getElementById("request-type-filter-dropdown").querySelectorAll(".request-filter-checkbox");s.forEach(o=>{const a=o.closest(".request-filter-option").dataset.value;o.checked=n.selectedRequestTypes.includes(a)}),n.selectedRequestTypes.length===0?e.textContent="None":n.selectedRequestTypes.length===s.length?e.textContent="All":e.textContent=n.selectedRequestTypes.length}function Rt(){const e=document.getElementById("request-status-filter-btn"),t=document.getElementById("request-status-filter-dropdown"),s=document.getElementById("request-status-select-all"),o=document.getElementById("request-status-deselect-all");if(!e||!t)return;e.addEventListener("click",r=>{r.stopPropagation(),t.classList.toggle("open")}),s==null||s.addEventListener("click",()=>ee(!0)),o==null||o.addEventListener("click",()=>ee(!1)),t.querySelectorAll(".request-filter-checkbox").forEach(r=>{r.addEventListener("change",()=>{const c=r.closest(".request-filter-option").dataset.value;Dt(c,r.checked)})}),H()}function ee(e){const s=document.getElementById("request-status-filter-dropdown").querySelectorAll(".request-filter-checkbox"),o=[];s.forEach(a=>{a.checked=e,e&&o.push(a.closest(".request-filter-option").dataset.value)}),n.selectedRequestStatuses=e?o:[],ie(n.selectedRequestStatuses),H(),q()}function Dt(e,t){const s=n.selectedRequestStatuses.indexOf(e);t&&s===-1?n.selectedRequestStatuses.push(e):!t&&s>-1&&n.selectedRequestStatuses.splice(s,1),ie(n.selectedRequestStatuses),H(),q()}function H(){const e=document.getElementById("request-status-selected-text");if(!e)return;const s=document.getElementById("request-status-filter-dropdown").querySelectorAll(".request-filter-checkbox");s.forEach(o=>{const a=o.closest(".request-filter-option").dataset.value;o.checked=n.selectedRequestStatuses.includes(a)}),n.selectedRequestStatuses.length===0||n.selectedRequestStatuses.length===s.length?e.textContent="All":e.textContent=n.selectedRequestStatuses.length}function At(){const e=document.getElementById("request-sort-select");e&&(e.value=n.requestSortMode,e.addEventListener("change",t=>{n.requestSortMode=t.target.value,Je(n.requestSortMode),q()}))}function Ft(){const e=document.getElementById("request-search-input");if(!e)return;e.value=n.requestSearchQuery;let t;e.addEventListener("input",s=>{clearTimeout(t),t=setTimeout(()=>{n.requestSearchQuery=s.target.value,Qe(n.requestSearchQuery),q()},200)})}function Mt(){document.addEventListener("click",e=>{const t=document.getElementById("request-type-filter-dropdown"),s=document.getElementById("request-type-filter-btn"),o=document.getElementById("request-status-filter-dropdown"),a=document.getElementById("request-status-filter-btn");t&&!t.contains(e.target)&&e.target!==s&&!(s!=null&&s.contains(e.target))&&t.classList.remove("open"),o&&!o.contains(e.target)&&e.target!==a&&!(a!=null&&a.contains(e.target))&&o.classList.remove("open")})}function $t(){xt(),Rt(),At(),Ft(),Mt(),document.addEventListener("ombiRequestsUpdated",()=>{q()})}(function(){const t=ae();t&&document.documentElement.setAttribute("data-theme",t)})();function Wt(){const e=document.getElementById("theme-toggle");e&&e.addEventListener("click",()=>{const s=ae()==="dark"?"light":"dark";Ht(s)})}function Ht(e){document.documentElement.setAttribute("data-theme",e),Ge(e)}function Ut(){const e=document.querySelector('[data-tab="downloads"]'),t=document.querySelector('[data-tab="requests"]'),s=document.querySelector('[data-tab="history"]');if(!e||!s)return;const o=ze();B(o==="requests"?"requests":o==="history"?"history":"downloads"),e.addEventListener("click",()=>B("downloads")),t&&t.addEventListener("click",()=>B("requests")),s.addEventListener("click",()=>B("history"))}function B(e){const t=document.querySelector('[data-tab="downloads"]'),s=document.querySelector('[data-tab="requests"]'),o=document.querySelector('[data-tab="history"]'),a=document.getElementById("tab-downloads"),r=document.getElementById("tab-requests"),c=document.getElementById("tab-history");t&&t.classList.remove("active"),s&&s.classList.remove("active"),o&&o.classList.remove("active"),a&&a.classList.add("hidden"),r&&r.classList.add("hidden"),c&&c.classList.add("hidden"),e==="downloads"?(t&&t.classList.add("active"),a&&a.classList.remove("hidden"),A("downloads")):e==="requests"&&(s&&s.classList.add("active"),r&&r.classList.remove("hidden"),A("requests"),setupRequestsTab(),o&&o.classList.add("active"),c&&c.classList.remove("hidden"),A("history"),x())}function Pt(){B("downloads")}document.addEventListener("DOMContentLoaded",()=>{const e=document.getElementById("login-form");e&&e.addEventListener("submit",ft);const t=document.getElementById("logout-btn");t&&t.addEventListener("click",gt);const s=document.getElementById("show-all-toggle");s&&s.addEventListener("change",r=>Ze(r.target.checked));const o=document.getElementById("status-btn");o&&o.addEventListener("click",ct);const a=document.getElementById("home-btn");a&&a.addEventListener("click",Pt),Wt(),Ut(),Et(),$t(),Ke(),et(),Le().then(r=>{const c=document.getElementById("app-version");c&&r&&(c.textContent="v"+r)}),pt()}); diff --git a/public/index.html b/public/index.html index 4939009..8908e42 100644 --- a/public/index.html +++ b/public/index.html @@ -201,7 +201,81 @@ ";const h=document.getElementById("status-content"),y=document.getElementById("status-panel");console.log("[Status] contentDiv found:",!!h,"panel children:",(J=y==null?void 0:y.children)==null?void 0:J.length,"HTML length:",d.length),y&&console.log("[Status] panel innerHTML preview:",y.innerHTML.substring(0,200)),h?(h.innerHTML=d,console.log("[Status] HTML rendered, contentDiv innerHTML length:",h.innerHTML.length)):console.error("[Status] contentDiv not found!");const b=document.getElementById("status-close-btn");b&&b.addEventListener("click",ut),t.querySelectorAll(".timing-bar[data-w]").forEach(g=>{g.style.width=g.dataset.w+"%"})}function N(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}function ht(){return new Promise(e=>{const t=document.getElementById("login-container");t.classList.add("fade-out"),t.addEventListener("transitionend",()=>{t.classList.add("hidden"),t.classList.remove("fade-out"),e()},{once:!0})})}function pt(){const e=document.getElementById("splash-screen");e.classList.remove("hidden"),e.style.opacity="1",e.classList.remove("fade-out")}function R(e){return new Promise(t=>{const s=Date.now()-(e||0),o=Math.max(0,ke-s);setTimeout(()=>{const a=document.getElementById("splash-screen");a.classList.add("fade-out");const c=setTimeout(()=>{a.classList.add("hidden"),t()},400+100);a.addEventListener("transitionend",()=>{clearTimeout(c),a.classList.add("hidden"),t()},{once:!0})},o)})}async function ft(){const e=Date.now();try{(await Ce()).authenticated?(pe(),fe(),H(),await R(e)):(await R(e),F())}catch(t){console.error("Authentication check failed:",t),await R(e),F()}}async function gt(e){e.preventDefault();const t=document.getElementById("username").value,s=document.getElementById("password").value,o=document.getElementById("remember-me").checked;try{const a=await Ie(t,s,o);if(a.success){await ht(),pt(),await new Promise(c=>requestAnimationFrame(()=>requestAnimationFrame(c))),pe(),fe();const r=Date.now();H(),await R(r)}else Z(a.error||"Login failed")}catch(a){Z("Login failed. Please try again."),console.error(a)}}async function bt(){try{me(),de(),n.statusRefreshHandle&&(clearInterval(n.statusRefreshHandle),n.statusRefreshHandle=null),await Be(),n.currentUser=null,Ye(),F()}catch(e){console.error("Logout failed:",e)}}function F(){document.getElementById("login-container").classList.remove("hidden"),document.getElementById("dashboard-container").classList.add("hidden"),yt()}function pe(){document.getElementById("login-container").classList.add("hidden"),document.getElementById("dashboard-container").classList.remove("hidden"),document.getElementById("currentUser").textContent=n.currentUser.name||"-",document.getElementById("status-panel").classList.add("hidden");const t=document.getElementById("webhooks-section");t&&t.classList.add("hidden");const s=document.getElementById("admin-controls");n.isAdmin?s.classList.remove("hidden"):s.classList.add("hidden");const o=document.getElementById("history-days");o&&(o.value=n.historyDays),Ve()}function Z(e){const t=document.getElementById("login-error");t.textContent=e,t.classList.remove("hidden")}function yt(){document.getElementById("login-error").classList.add("hidden")}function vt(){document.getElementById("error-message").classList.add("hidden")}function fe(){document.getElementById("loading").classList.remove("hidden")}function Et(){document.getElementById("loading").classList.add("hidden")}function kt(){const e=document.getElementById("download-client-dropdown-btn"),t=document.getElementById("download-client-dropdown"),s=document.getElementById("download-client-select-all"),o=document.getElementById("download-client-deselect-all");!e||!t||(e.addEventListener("click",a=>{a.stopPropagation(),t.classList.toggle("open")}),s&&s.addEventListener("click",()=>ee(!0)),o&&o.addEventListener("click",()=>ee(!1)),document.addEventListener("click",a=>{!t.contains(a.target)&&a.target!==e&&!e.contains(a.target)&&t.classList.remove("open")}),document.addEventListener("downloadClientsUpdated",M),M())}function M(){const e=document.getElementById("download-client-options");e&&(e.innerHTML="",n.downloadClients.forEach((t,s)=>{const o=document.createElement("div");o.className="filter-item",o.dataset.index=s;const a=document.createElement("input");a.type="checkbox",a.id=`client-${s}`,a.checked=n.selectedDownloadClients.includes(s),a.addEventListener("change",()=>wt(s));const r=document.createElement("label");r.htmlFor=`client-${s}`,r.textContent=t.name||`${t.type} (${t.id})`,o.appendChild(a),o.appendChild(r),e.appendChild(o)}),ge())}function wt(e){const t=n.selectedDownloadClients.indexOf(e);t>-1?n.selectedDownloadClients.splice(t,1):n.selectedDownloadClients.push(e),re(n.selectedDownloadClients),ge(),W()}function ee(e){e?n.selectedDownloadClients=n.downloadClients.map((t,s)=>s):n.selectedDownloadClients=[],re(n.selectedDownloadClients),M(),W()}function ge(){const e=document.getElementById("download-client-selected-text");if(e)if(n.selectedDownloadClients.length===0)e.textContent="All clients";else if(n.selectedDownloadClients.length===n.downloadClients.length)e.textContent="All clients";else{const t=n.selectedDownloadClients.map(s=>{var o,a;return((o=n.downloadClients[s])==null?void 0:o.name)||((a=n.downloadClients[s])==null?void 0:a.type)||""}).filter(Boolean);t.length===1?e.textContent=t[0]:e.textContent=`${n.selectedDownloadClients.length} clients`}}function be(e){return e?e.available?"available":e.denied?"denied":e.approved?"approved":e.requested?"pending":"unknown":"unknown"}function St(e,t){if(!t||t.length===0)return e;const s=t.map(o=>o.toLowerCase());return s.includes("all")?e:e.filter(o=>s.includes(o.mediaType))}function Ct(e,t){if(!t||t.length===0)return e;const s=t.map(o=>o.toLowerCase());return e.filter(o=>s.includes(be(o)))}function It(e,t){if(!t||t.trim()==="")return e;const s=t.trim().toLowerCase();return e.filter(o=>(o.title||"").toLowerCase().includes(s))}function Bt(e,t){const s=[...e];switch(t){case"requestedDate_asc":return s.sort((o,a)=>{const r=o.requestedDate?new Date(o.requestedDate).getTime():0,c=a.requestedDate?new Date(a.requestedDate).getTime():0;return r-c});case"title_asc":return s.sort((o,a)=>(o.title||"").localeCompare(a.title||""));case"title_desc":return s.sort((o,a)=>(a.title||"").localeCompare(o.title||""));case"requestedDate_desc":default:return s.sort((o,a)=>{const r=o.requestedDate?new Date(o.requestedDate).getTime():0;return(a.requestedDate?new Date(a.requestedDate).getTime():0)-r})}}function Lt(e,{types:t,statuses:s,sort:o,search:a}={}){let r=[...e];return r=St(r,t),r=Ct(r,s),r=It(r,a),r=Bt(r,o),r}function qt(e){return e?e.requestedUser&&typeof e.requestedUser=="object"?e.requestedUser.alias||e.requestedUser.userAlias||e.requestedUser.userName||e.requestedUser.normalizedUserName||e.requestedByAlias||"":e.requestedUser||e.requestedByAlias||"":""}function S(){const e=document.getElementById("requests-list"),t=document.getElementById("no-requests");if(!e)return;const s=n.ombiRequests||{movie:[],tv:[]},o=[...s.movie.map(r=>({...r,mediaType:"movie"})),...s.tv.map(r=>({...r,mediaType:"tv"}))],a=Lt(o,{types:n.selectedRequestTypes,statuses:n.selectedRequestStatuses,sort:n.requestSortMode,search:n.requestSearchQuery});if(e.innerHTML="",a.length===0){if(t){t.style.display="block";const r=t.querySelector("p");if(r){const c=o.length>0;r.textContent=c?"No requests match your filters.":"No requests found."}}return}t&&(t.style.display="none"),a.forEach(r=>{const c=Tt(r);e.appendChild(c)})}function Tt(e){if(!e){const l=document.createElement("div");return l.className="request-card",l.textContent="Invalid request data",l}const t=document.createElement("div");t.className="request-card";const s=document.createElement("span");s.className=`request-type-icon ${e.mediaType||""}`,s.textContent=e.mediaType==="movie"?"🎬":"📺";const o=document.createElement("div");o.className="request-content";const a=document.createElement("div");a.className="request-title",a.textContent=e.title||"Unknown Title";const r=document.createElement("div");r.className="request-meta";const c=xt(e);if(r.appendChild(c),e.year){const l=document.createElement("span");l.className="request-year",l.textContent=e.year,r.appendChild(l)}const m=qt(e);if(m){const l=document.createElement("span");l.className="request-user",l.textContent=`Requested by: ${m}`,r.appendChild(l)}if(e.quality){const l=document.createElement("span");l.className="request-quality",l.textContent=e.quality,r.appendChild(l)}o.appendChild(a),o.appendChild(r);const d=document.createElement("div");if(d.className="request-actions",n.ombiBaseUrl&&e.theMovieDbId){const l=document.createElement("a");l.className="request-link ombi-link",l.href=`${n.ombiBaseUrl}/details/${e.mediaType||"movie"}/${e.theMovieDbId}`,l.target="_blank",l.title="View in Ombi";const f=document.createElement("img");f.src="/images/ombi.svg",f.alt="Ombi",f.className="request-icon",l.appendChild(f),d.appendChild(l)}return t.appendChild(s),t.appendChild(o),t.appendChild(d),t}function xt(e){const t=document.createElement("span");t.className="request-status-badge";const s=be(e),o={available:"Available",denied:`Denied: ${e.deniedReason||"No reason"}`,approved:"Approved",pending:"Pending",unknown:"Unknown"};return t.classList.add(s),t.textContent=o[s]||"Unknown",t}function Nt(){const e=document.getElementById("request-type-filter-btn"),t=document.getElementById("request-type-filter-dropdown"),s=document.getElementById("request-type-select-all"),o=document.getElementById("request-type-deselect-all");if(!e||!t)return;e.addEventListener("click",r=>{r.stopPropagation(),t.classList.toggle("open")}),s==null||s.addEventListener("click",()=>te(!0)),o==null||o.addEventListener("click",()=>te(!1)),t.querySelectorAll(".request-filter-checkbox").forEach(r=>{r.addEventListener("change",()=>{const c=r.closest(".request-filter-option").dataset.value;Rt(c,r.checked)})}),U()}function te(e){const s=document.getElementById("request-type-filter-dropdown").querySelectorAll(".request-filter-checkbox"),o=[];s.forEach(a=>{a.checked=e,e&&o.push(a.closest(".request-filter-option").dataset.value)}),n.selectedRequestTypes=e?o:[],le(n.selectedRequestTypes),U(),S()}function Rt(e,t){const s=n.selectedRequestTypes.indexOf(e);t&&s===-1?n.selectedRequestTypes.push(e):!t&&s>-1&&n.selectedRequestTypes.splice(s,1),le(n.selectedRequestTypes),U(),S()}function U(){const e=document.getElementById("request-type-selected-text");if(!e)return;const s=document.getElementById("request-type-filter-dropdown").querySelectorAll(".request-filter-checkbox");s.forEach(o=>{const a=o.closest(".request-filter-option").dataset.value;o.checked=n.selectedRequestTypes.includes(a)}),n.selectedRequestTypes.length===0||n.selectedRequestTypes.length===s.length?e.textContent="All":e.textContent=n.selectedRequestTypes.length}function Dt(){const e=document.getElementById("request-status-filter-btn"),t=document.getElementById("request-status-filter-dropdown"),s=document.getElementById("request-status-select-all"),o=document.getElementById("request-status-deselect-all");if(!e||!t)return;e.addEventListener("click",r=>{r.stopPropagation(),t.classList.toggle("open")}),s==null||s.addEventListener("click",()=>se(!0)),o==null||o.addEventListener("click",()=>se(!1)),t.querySelectorAll(".request-filter-checkbox").forEach(r=>{r.addEventListener("change",()=>{const c=r.closest(".request-filter-option").dataset.value;At(c,r.checked)})}),P()}function se(e){const s=document.getElementById("request-status-filter-dropdown").querySelectorAll(".request-filter-checkbox"),o=[];s.forEach(a=>{a.checked=e,e&&o.push(a.closest(".request-filter-option").dataset.value)}),n.selectedRequestStatuses=e?o:[],ce(n.selectedRequestStatuses),P(),S()}function At(e,t){const s=n.selectedRequestStatuses.indexOf(e);t&&s===-1?n.selectedRequestStatuses.push(e):!t&&s>-1&&n.selectedRequestStatuses.splice(s,1),ce(n.selectedRequestStatuses),P(),S()}function P(){const e=document.getElementById("request-status-selected-text");if(!e)return;const s=document.getElementById("request-status-filter-dropdown").querySelectorAll(".request-filter-checkbox");s.forEach(o=>{const a=o.closest(".request-filter-option").dataset.value;o.checked=n.selectedRequestStatuses.includes(a)}),n.selectedRequestStatuses.length===0||n.selectedRequestStatuses.length===s.length?e.textContent="All":e.textContent=n.selectedRequestStatuses.length}function Ft(){const e=document.getElementById("request-sort-select");e&&(e.value=n.requestSortMode,e.addEventListener("change",t=>{n.requestSortMode=t.target.value,Qe(n.requestSortMode),S()}))}function Mt(){const e=document.getElementById("request-search-input");if(!e)return;e.value=n.requestSearchQuery;let t;e.addEventListener("input",s=>{clearTimeout(t),t=setTimeout(()=>{n.requestSearchQuery=s.target.value,Ke(n.requestSearchQuery),S()},200)})}function $t(){document.addEventListener("click",e=>{const t=document.getElementById("request-type-filter-dropdown"),s=document.getElementById("request-type-filter-btn"),o=document.getElementById("request-status-filter-dropdown"),a=document.getElementById("request-status-filter-btn");t&&!t.contains(e.target)&&e.target!==s&&!(s!=null&&s.contains(e.target))&&t.classList.remove("open"),o&&!o.contains(e.target)&&e.target!==a&&!(a!=null&&a.contains(e.target))&&o.classList.remove("open")})}function Wt(){Nt(),Dt(),Ft(),Mt(),$t(),document.addEventListener("ombiRequestsUpdated",()=>{S()})}(function(){const t=ie();t&&document.documentElement.setAttribute("data-theme",t)})();function Ht(){const e=document.getElementById("theme-toggle");e&&e.addEventListener("click",()=>{const s=ie()==="dark"?"light":"dark";Ut(s)})}function Ut(e){document.documentElement.setAttribute("data-theme",e),ze(e)}function Pt(){const e=document.querySelector('[data-tab="downloads"]'),t=document.querySelector('[data-tab="requests"]'),s=document.querySelector('[data-tab="history"]');if(!e||!s)return;const o=Je();L(o==="requests"?"requests":o==="history"?"history":"downloads"),e.addEventListener("click",()=>L("downloads")),t&&t.addEventListener("click",()=>L("requests")),s.addEventListener("click",()=>L("history"))}function L(e){const t=document.querySelector('[data-tab="downloads"]'),s=document.querySelector('[data-tab="requests"]'),o=document.querySelector('[data-tab="history"]'),a=document.getElementById("tab-downloads"),r=document.getElementById("tab-requests"),c=document.getElementById("tab-history");t&&t.classList.remove("active"),s&&s.classList.remove("active"),o&&o.classList.remove("active"),a&&a.classList.add("hidden"),r&&r.classList.add("hidden"),c&&c.classList.add("hidden"),e==="downloads"?(t&&t.classList.add("active"),a&&a.classList.remove("hidden"),A("downloads")):e==="requests"?(s&&s.classList.add("active"),r&&r.classList.remove("hidden"),A("requests"),S()):e==="history"&&(o&&o.classList.add("active"),c&&c.classList.remove("hidden"),A("history"),x())}function Ot(){L("downloads")}document.addEventListener("DOMContentLoaded",()=>{const e=document.getElementById("login-form");e&&e.addEventListener("submit",gt);const t=document.getElementById("logout-btn");t&&t.addEventListener("click",bt);const s=document.getElementById("show-all-toggle");s&&s.addEventListener("change",r=>et(r.target.checked));const o=document.getElementById("status-btn");o&&o.addEventListener("click",dt);const a=document.getElementById("home-btn");a&&a.addEventListener("click",Ot),Ht(),Pt(),kt(),Wt(),Xe(),tt(),Te().then(r=>{const c=document.getElementById("app-version");c&&r&&(c.textContent="v"+r)}),ft()}); diff --git a/tests/frontend/ui/filters.test.js b/tests/frontend/ui/filters.test.js new file mode 100644 index 0000000..9c41287 --- /dev/null +++ b/tests/frontend/ui/filters.test.js @@ -0,0 +1,171 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. +/** + * @vitest-environment jsdom + * Tests for client/src/ui/filters.js + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { state } from '../../../client/src/state.js'; +import { initDownloadClientFilter, updateDownloadClientFilter, toggleClientSelection, toggleAllClients, updateSelectedCountDisplay } from '../../../client/src/ui/filters.js'; +import { renderDownloads } from '../../../client/src/ui/downloads.js'; + +// Mock renderDownloads to verify re-render triggers +vi.mock('../../../client/src/ui/downloads.js', () => ({ + renderDownloads: vi.fn() +})); + +// Mock localStorage +const localStorageMock = (() => { + let store = {}; + return { + getItem: (key) => store[key] || null, + setItem: (key, value) => { store[key] = value; }, + removeItem: (key) => { delete store[key]; }, + clear: () => { store = {}; } + }; +})(); + +Object.defineProperty(window, 'localStorage', { + value: localStorageMock +}); + +function setupDOM() { + document.body.innerHTML = ` +
+
+ +
+
+ + +
+
+ +
+
+
+
+ `; +} + +describe('initDownloadClientFilter', () => { + beforeEach(() => { + localStorageMock.clear(); + state.downloadClients = [ + { id: 1, type: 'sabnzbd', name: 'SABnzbd' }, + { id: 2, type: 'qbittorrent', name: 'qBittorrent' } + ]; + state.selectedDownloadClients = []; + vi.clearAllMocks(); + setupDOM(); + initDownloadClientFilter(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('populates options list with checkboxes matching download clients', () => { + const optionsList = document.getElementById('download-client-options'); + expect(optionsList.children.length).toBe(2); + + const firstItem = optionsList.children[0]; + const checkbox = firstItem.querySelector('input'); + const label = firstItem.querySelector('label'); + + expect(checkbox.type).toBe('checkbox'); + expect(checkbox.checked).toBe(false); + expect(label.textContent).toBe('SABnzbd'); + }); + + it('restores checked state based on state.selectedDownloadClients', () => { + state.selectedDownloadClients = [0]; + updateDownloadClientFilter(); + + const optionsList = document.getElementById('download-client-options'); + const firstCheckbox = optionsList.children[0].querySelector('input'); + const secondCheckbox = optionsList.children[1].querySelector('input'); + + expect(firstCheckbox.checked).toBe(true); + expect(secondCheckbox.checked).toBe(false); + }); + + it('clicking a checkbox updates selected state and triggers re-render', () => { + const optionsList = document.getElementById('download-client-options'); + const firstCheckbox = optionsList.children[0].querySelector('input'); + + firstCheckbox.click(); + + expect(state.selectedDownloadClients).toEqual([0]); + expect(localStorageMock.getItem('sofarr-download-clients')).toBe(JSON.stringify([0])); + expect(renderDownloads).toHaveBeenCalled(); + }); + + it('select all selects all clients and saves to storage', () => { + document.getElementById('download-client-select-all').click(); + + expect(state.selectedDownloadClients).toEqual([0, 1]); + expect(localStorageMock.getItem('sofarr-download-clients')).toBe(JSON.stringify([0, 1])); + expect(renderDownloads).toHaveBeenCalled(); + + const optionsList = document.getElementById('download-client-options'); + expect(optionsList.children[0].querySelector('input').checked).toBe(true); + expect(optionsList.children[1].querySelector('input').checked).toBe(true); + }); + + it('deselect all clears all clients and saves empty list to storage', () => { + state.selectedDownloadClients = [0, 1]; + updateDownloadClientFilter(); + + document.getElementById('download-client-deselect-all').click(); + + expect(state.selectedDownloadClients).toEqual([]); + expect(localStorageMock.getItem('sofarr-download-clients')).toBe(JSON.stringify([])); + expect(renderDownloads).toHaveBeenCalled(); + + const optionsList = document.getElementById('download-client-options'); + expect(optionsList.children[0].querySelector('input').checked).toBe(false); + expect(optionsList.children[1].querySelector('input').checked).toBe(false); + }); + + it('toggles dropdown when dropdown button is clicked', () => { + const dropdown = document.getElementById('download-client-dropdown'); + const btn = document.getElementById('download-client-dropdown-btn'); + + btn.click(); + expect(dropdown.classList.contains('open')).toBe(true); + + btn.click(); + expect(dropdown.classList.contains('open')).toBe(false); + }); + + it('closes dropdown when clicking outside', () => { + const dropdown = document.getElementById('download-client-dropdown'); + const btn = document.getElementById('download-client-dropdown-btn'); + + btn.click(); + expect(dropdown.classList.contains('open')).toBe(true); + + document.body.click(); + expect(dropdown.classList.contains('open')).toBe(false); + }); + + it('updates selected text display correctly based on count', () => { + const selectedText = document.getElementById('download-client-selected-text'); + + state.selectedDownloadClients = []; + updateSelectedCountDisplay(); + expect(selectedText.textContent).toBe('All clients'); + + state.selectedDownloadClients = [0]; + updateSelectedCountDisplay(); + expect(selectedText.textContent).toBe('SABnzbd'); + + state.selectedDownloadClients = [0, 1]; + updateSelectedCountDisplay(); + expect(selectedText.textContent).toBe('All clients'); // Since it's all of them + }); +}); From e3f90d54f4b0fc07ac73087837fcce38ddb567f3 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 23 May 2026 09:36:41 +0100 Subject: [PATCH 19/20] fix(ui): add brand icons and type badges to download client filter dropdown (resolves #39) --- client/src/ui/filters.js | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/client/src/ui/filters.js b/client/src/ui/filters.js index 25eb145..a8f56b9 100644 --- a/client/src/ui/filters.js +++ b/client/src/ui/filters.js @@ -46,21 +46,40 @@ export function updateDownloadClientFilter() { state.downloadClients.forEach((client, index) => { const item = document.createElement('div'); - item.className = 'filter-item'; + item.className = 'download-client-option'; item.dataset.index = index; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; + checkbox.className = 'download-client-checkbox'; checkbox.id = `client-${index}`; checkbox.checked = state.selectedDownloadClients.includes(index); checkbox.addEventListener('change', () => toggleClientSelection(index)); + 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 || client.type} icon`; + iconImg.onerror = () => { + 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 = `client-${index}`; label.textContent = client.name || `${client.type} (${client.id})`; + const typeBadge = document.createElement('span'); + typeBadge.className = 'download-client-type'; + typeBadge.textContent = client.type; + item.appendChild(checkbox); + item.appendChild(iconWrapper); item.appendChild(label); + item.appendChild(typeBadge); filterList.appendChild(item); }); From f52a687a4627a1aa8360b3b955df289cba3f3ff3 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sat, 23 May 2026 09:39:58 +0100 Subject: [PATCH 20/20] chore: bump version to 1.7.3 and update CHANGELOG --- CHANGELOG.md | 8 ++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f02c74a..d247b16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm --- +## [1.7.3] - 2026-05-23 + +### Fixed + +- **Download client dropdown filter icons and type badges** — Refactored the download client selector dropdown in `client/src/ui/filters.js` to render utilizing the design system classes `.download-client-option`, `.download-client-icon`, `.download-client-option-label`, and `.download-client-type`. Options now display the client's brand SVG logo (e.g., `sabnzbd.svg`, `qbittorrent.svg` loaded from `/images/clients/`) next to the configured display name, along with a clean pill badge indicating the client type. This resolves visual ambiguity when multiple download clients share the same name (such as `"i3omb"` for SABnzbd and qBittorrent). + +--- + ## [1.7.2] - 2026-05-22 ### Fixed diff --git a/package-lock.json b/package-lock.json index c7a9543..e83a923 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sofarr", - "version": "1.7.0", + "version": "1.7.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sofarr", - "version": "1.7.0", + "version": "1.7.3", "license": "MIT", "dependencies": { "axios": "^1.6.0", diff --git a/package.json b/package.json index 545150e..004ddbd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sofarr", - "version": "1.7.2", + "version": "1.7.3", "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": {