From 8dc105ff3ec6a1b9c54ddd3a591fee10ef12ef70 Mon Sep 17 00:00:00 2001 From: Gronod Date: Wed, 20 May 2026 23:30:24 +0100 Subject: [PATCH] Migrate frontend from monolithic app.js to vanilla ES modules - Delete React files (App.jsx, main.jsx, App.css) - Create modular vanilla JS structure in client/src/: - state.js (global state object) - api.js (all fetch calls) - sse.js (SSE connection management) - ui/auth.js (authentication UI) - ui/downloads.js (downloads rendering) - ui/history.js (history section) - ui/statusPanel.js (status panel) - ui/webhooks.js (webhook management) - ui/filters.js (download client filter) - ui/theme.js (theme switching) - ui/tabs.js (tab navigation) - utils/format.js (formatting utilities) - utils/storage.js (localStorage helpers) - main.js (DOMContentLoaded bootstrap) - Update vite.config.js for vanilla build outputting to ../public/app.js - Build succeeds: 14 modules, 43.88 kB output --- client/src/App.css | 499 --------- client/src/App.jsx | 483 --------- client/src/api.js | 292 ++++++ client/src/main.js | 62 ++ client/src/main.jsx | 11 - client/src/sse.js | 60 ++ client/src/state.js | 38 + client/src/ui/auth.js | 171 +++ client/src/ui/downloads.js | 566 ++++++++++ client/src/ui/filters.js | 87 ++ client/src/ui/history.js | 244 +++++ client/src/ui/statusPanel.js | 194 ++++ client/src/ui/tabs.js | 50 + client/src/ui/theme.js | 27 + client/src/ui/webhooks.js | 192 ++++ client/src/utils/format.js | 74 ++ client/src/utils/storage.js | 76 ++ client/vite.config.js | 16 +- public/app.js | 1885 +--------------------------------- 19 files changed, 2173 insertions(+), 2854 deletions(-) delete mode 100644 client/src/App.css delete mode 100644 client/src/App.jsx create mode 100644 client/src/api.js create mode 100644 client/src/main.js delete mode 100644 client/src/main.jsx create mode 100644 client/src/sse.js create mode 100644 client/src/state.js create mode 100644 client/src/ui/auth.js create mode 100644 client/src/ui/downloads.js create mode 100644 client/src/ui/filters.js create mode 100644 client/src/ui/history.js create mode 100644 client/src/ui/statusPanel.js create mode 100644 client/src/ui/tabs.js create mode 100644 client/src/ui/theme.js create mode 100644 client/src/ui/webhooks.js create mode 100644 client/src/utils/format.js create mode 100644 client/src/utils/storage.js diff --git a/client/src/App.css b/client/src/App.css deleted file mode 100644 index 084372a..0000000 --- a/client/src/App.css +++ /dev/null @@ -1,499 +0,0 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - min-height: 100vh; -} - -.app { - min-height: 100vh; - padding: 20px; - max-width: 1200px; - margin: 0 auto; -} - -.app-header { - background: white; - padding: 30px; - border-radius: 10px; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); - margin-bottom: 20px; - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: 20px; -} - -.app-header h1 { - color: #333; - font-size: 2rem; -} - -.user-info { - display: flex; - align-items: center; - gap: 10px; - background: #f0f0f0; - padding: 10px 20px; - border-radius: 20px; -} - -.user-label { - color: #666; - font-weight: 500; -} - -.user-name { - color: #667eea; - font-weight: bold; - font-size: 1.1rem; -} - -.controls { - background: white; - padding: 20px; - border-radius: 10px; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); - margin-bottom: 20px; - display: flex; - gap: 15px; - align-items: center; - flex-wrap: wrap; -} - -.controls label { - color: #333; - font-weight: 500; -} - -.session-select { - flex: 1; - min-width: 200px; - padding: 10px; - border: 2px solid #e0e0e0; - border-radius: 5px; - font-size: 1rem; - cursor: pointer; -} - -.session-select:focus { - outline: none; - border-color: #667eea; -} - -.refresh-btn { - padding: 10px 20px; - background: #667eea; - color: white; - border: none; - border-radius: 5px; - cursor: pointer; - font-size: 1rem; - transition: background 0.3s; -} - -.refresh-btn:hover { - background: #5568d3; -} - -.error-message { - background: #fee; - color: #c33; - padding: 15px; - border-radius: 10px; - margin-bottom: 20px; - border-left: 4px solid #c33; -} - -.loading { - text-align: center; - padding: 40px; - color: white; - font-size: 1.2rem; -} - -.downloads-container { - background: white; - padding: 30px; - border-radius: 10px; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); -} - -.downloads-container h2 { - color: #333; - margin-bottom: 20px; - font-size: 1.5rem; -} - -.no-downloads { - text-align: center; - padding: 40px; - color: #666; -} - -.no-downloads p { - margin: 10px 0; -} - -.downloads-list { - display: grid; - gap: 20px; -} - -.download-card { - border: 2px solid #e0e0e0; - border-radius: 10px; - padding: 20px; - transition: transform 0.2s, box-shadow 0.2s; - display: flex; - gap: 20px; - align-items: flex-start; -} - -.download-cover { - flex-shrink: 0; - width: 80px; - border-radius: 6px; - overflow: hidden; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); -} - -.download-cover img { - width: 100%; - height: auto; - display: block; -} - -.download-info { - flex: 1; - min-width: 0; -} - -.download-card:hover { - transform: translateY(-2px); - box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1); -} - -.download-card.series { - border-left: 4px solid #667eea; -} - -.download-card.movie { - border-left: 4px solid #f093fb; -} - -.download-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 15px; -} - -.download-type { - padding: 5px 15px; - border-radius: 20px; - font-size: 0.9rem; - font-weight: 500; -} - -.download-type.series { - background: #e8eaf6; - color: #667eea; -} - -.download-type.movie { - background: #fce4ec; - color: #f093fb; -} - -.download-status { - padding: 5px 15px; - border-radius: 20px; - font-size: 0.9rem; - font-weight: 500; - text-transform: capitalize; -} - -.download-status.downloading { - background: #e8f5e9; - color: #4caf50; -} - -.download-status.completed { - background: #e3f2fd; - color: #2196f3; -} - -.download-status.failed { - background: #ffebee; - color: #f44336; -} - -.download-title { - color: #333; - margin-bottom: 10px; - font-size: 1.2rem; -} - -.download-series, -.download-movie { - color: #666; - margin-bottom: 15px; - font-style: italic; -} - -.download-details { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 15px; - padding-top: 15px; - border-top: 1px solid #e0e0e0; -} - -.detail-item { - display: flex; - flex-direction: column; - gap: 5px; -} - -.detail-label { - color: #999; - font-size: 0.85rem; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.detail-value { - color: #333; - font-weight: 500; -} - -.app-footer { - margin-top: 20px; - text-align: center; - color: white; - font-size: 0.9rem; -} - -.app-footer p { - opacity: 0.9; -} - -@media (max-width: 768px) { - .app-header { - flex-direction: column; - align-items: flex-start; - } - - .controls { - flex-direction: column; - align-items: stretch; - } - - .download-details { - grid-template-columns: 1fr; - } -} - -/* Webhooks Section Styles */ -.webhooks-section { - background: white; - border-radius: 10px; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); - margin-bottom: 20px; - overflow: hidden; -} - -.webhooks-header { - padding: 20px 30px; - background: #f8f9fa; - border-bottom: 2px solid #e0e0e0; - cursor: pointer; - display: flex; - justify-content: space-between; - align-items: center; - transition: background 0.3s; -} - -.webhooks-header:hover { - background: #f0f1f2; -} - -.webhooks-header h2 { - color: #333; - font-size: 1.3rem; - margin: 0; -} - -.webhooks-toggle { - font-size: 1.2rem; - color: #666; - transition: transform 0.3s; -} - -.webhooks-toggle.expanded { - transform: rotate(180deg); -} - -.webhooks-content { - padding: 20px 30px; -} - -.webhook-instance { - padding: 20px 0; - border-bottom: 1px solid #e0e0e0; -} - -.webhook-instance:last-child { - border-bottom: none; -} - -.webhook-instance h3 { - color: #333; - font-size: 1.1rem; - margin-bottom: 15px; -} - -.webhook-status { - display: flex; - align-items: center; - gap: 15px; - margin-bottom: 15px; -} - -.status-indicator { - font-size: 1rem; - font-weight: 500; - padding: 5px 15px; - border-radius: 20px; -} - -.status-indicator.enabled { - background: #e8f5e9; - color: #4caf50; -} - -.status-indicator.disabled { - background: #f5f5f5; - color: #999; -} - -.enable-webhook-btn { - padding: 8px 16px; - background: #667eea; - color: white; - border: none; - border-radius: 5px; - cursor: pointer; - font-size: 0.95rem; - transition: background 0.3s; -} - -.enable-webhook-btn:hover { - background: #5568d3; -} - -.enable-webhook-btn:disabled { - background: #ccc; - cursor: not-allowed; -} - -.test-webhook-btn { - padding: 8px 16px; - background: #f093fb; - color: white; - border: none; - border-radius: 5px; - cursor: pointer; - font-size: 0.95rem; - transition: background 0.3s; -} - -.test-webhook-btn:hover { - background: #d97ed8; -} - -.test-webhook-btn:disabled { - background: #ccc; - cursor: not-allowed; -} - -.webhook-triggers { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 10px; - padding-top: 15px; - border-top: 1px solid #e0e0e0; -} - -.trigger-item { - display: flex; - justify-content: space-between; - align-items: center; -} - -.trigger-label { - color: #666; - font-size: 0.9rem; -} - -.trigger-value { - font-weight: 500; - font-size: 1.1rem; -} - -.trigger-value.active { - color: #4caf50; -} - -.trigger-value.inactive { - color: #999; -} - -.webhook-stats { - margin-top: 15px; - padding-top: 15px; - border-top: 1px solid #e0e0e0; -} - -.webhook-stats-title { - color: #999; - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.6px; - margin-bottom: 10px; -} - -.webhook-stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); - gap: 10px; -} - -.webhook-stat { - display: flex; - flex-direction: column; - gap: 3px; -} - -.webhook-stat-label { - color: #999; - font-size: 0.8rem; -} - -.webhook-stat-value { - color: #333; - font-size: 0.95rem; - font-weight: 500; -} diff --git a/client/src/App.jsx b/client/src/App.jsx deleted file mode 100644 index 6b27a6d..0000000 --- a/client/src/App.jsx +++ /dev/null @@ -1,483 +0,0 @@ -// Copyright (c) 2026 Gordon Bolton. MIT License. -import { useState, useEffect } from 'react'; -import axios from 'axios'; -import './App.css'; - -function App() { - const [sessionId, setSessionId] = useState(''); - const [currentUser, setCurrentUser] = useState(null); - const [downloads, setDownloads] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [sessions, setSessions] = useState([]); - const [webhookSectionExpanded, setWebhookSectionExpanded] = useState(false); - const [sonarrWebhook, setSonarrWebhook] = useState({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null }); - const [radarrWebhook, setRadarrWebhook] = useState({ enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null }); - const [webhookMetrics, setWebhookMetrics] = useState(null); - const [webhookLoading, setWebhookLoading] = useState(false); - - useEffect(() => { - fetchSessions(); - fetchWebhookStatus(); - }, []); - - const fetchSessions = async () => { - try { - const response = await axios.get('/api/emby/sessions'); - setSessions(response.data); - - // Auto-select first active session - const activeSession = response.data.find(s => s.NowPlayingItem || s.Active); - if (activeSession) { - setSessionId(activeSession.Id); - fetchUserDownloads(activeSession.Id); - } - } catch (err) { - setError('Failed to fetch Emby sessions. Make sure Emby is running and configured.'); - console.error(err); - } - }; - - const fetchUserDownloads = async (sessionId) => { - setLoading(true); - setError(null); - try { - const response = await axios.get(`/api/dashboard/user-downloads/${sessionId}`); - setCurrentUser(response.data.user); - setDownloads(response.data.downloads); - } catch (err) { - setError('Failed to fetch downloads. Make sure all services are configured.'); - console.error(err); - } finally { - setLoading(false); - } - }; - - const handleSessionChange = (e) => { - const newSessionId = e.target.value; - setSessionId(newSessionId); - if (newSessionId) { - fetchUserDownloads(newSessionId); - } - }; - - const formatSize = (bytes) => { - if (!bytes) return 'N/A'; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; - }; - - const formatDate = (dateString) => { - if (!dateString) return 'N/A'; - return new Date(dateString).toLocaleString(); - }; - - const formatTimeAgo = (timestamp) => { - if (!timestamp) return 'Never'; - const seconds = Math.floor((Date.now() - timestamp) / 1000); - if (seconds < 60) return `${seconds}s ago`; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - return `${Math.floor(hours / 24)}d ago`; - }; - - const fetchWebhookMetrics = async () => { - try { - const response = await axios.get('/api/dashboard/webhook-metrics'); - setWebhookMetrics(response.data); - return response.data; - } catch (err) { - // Not fatal β€” stats just won't display - return null; - } - }; - - const fetchWebhookStatus = async () => { - try { - // Fetch metrics in parallel with notification status - const metricsPromise = fetchWebhookMetrics(); - - // Fetch Sonarr notifications - let sonarrEnabled = false; - let sonarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }; - try { - const sonarrResponse = await axios.get('/api/sonarr/notifications'); - const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr'); - sonarrEnabled = !!sonarrSofarr; - if (sonarrSofarr) { - sonarrTriggers = { - onGrab: sonarrSofarr.onGrab, - onDownload: sonarrSofarr.onDownload, - onImport: sonarrSofarr.onImport, - onUpgrade: sonarrSofarr.onUpgrade - }; - } - } catch (err) { - // Sonarr not configured or not accessible - } - - // Fetch Radarr notifications - let radarrEnabled = false; - let radarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }; - try { - const radarrResponse = await axios.get('/api/radarr/notifications'); - const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr'); - radarrEnabled = !!radarrSofarr; - if (radarrSofarr) { - radarrTriggers = { - onGrab: radarrSofarr.onGrab, - onDownload: radarrSofarr.onDownload, - onImport: radarrSofarr.onImport, - onUpgrade: radarrSofarr.onUpgrade - }; - } - } catch (err) { - // Radarr not configured or not accessible - } - - const metrics = await metricsPromise; - - // Attach per-instance stats from global metrics. - // The instances object is keyed by instance URL; we pick the first - // sonarr/radarr entry by matching env-configured URLs. - const instanceEntries = metrics ? Object.entries(metrics.instances || {}) : []; - const sonarrStats = instanceEntries.find(([url]) => url.includes('sonarr'))?.[1] || null; - const radarrStats = instanceEntries.find(([url]) => url.includes('radarr'))?.[1] || null; - - setSonarrWebhook({ enabled: sonarrEnabled, triggers: sonarrTriggers, stats: sonarrStats }); - setRadarrWebhook({ enabled: radarrEnabled, triggers: radarrTriggers, stats: radarrStats }); - } catch (err) { - console.error('Failed to fetch webhook status:', err); - } - }; - - const enableSonarrWebhook = async () => { - setWebhookLoading(true); - try { - await axios.post('/api/sonarr/notifications/sofarr-webhook'); - await fetchWebhookStatus(); - } catch (err) { - console.error('Failed to enable Sonarr webhook:', err); - alert('Failed to enable Sonarr webhook. Check console for details.'); - } finally { - setWebhookLoading(false); - } - }; - - const enableRadarrWebhook = async () => { - setWebhookLoading(true); - try { - await axios.post('/api/radarr/notifications/sofarr-webhook'); - await fetchWebhookStatus(); - } catch (err) { - console.error('Failed to enable Radarr webhook:', err); - alert('Failed to enable Radarr webhook. Check console for details.'); - } finally { - setWebhookLoading(false); - } - }; - - const testSonarrWebhook = async () => { - setWebhookLoading(true); - try { - const sonarrResponse = await axios.get('/api/sonarr/notifications'); - const sonarrSofarr = sonarrResponse.data.find(n => n.name === 'Sofarr'); - if (sonarrSofarr) { - await axios.post('/api/sonarr/notifications/test', { id: sonarrSofarr.id }); - await fetchWebhookStatus(); - alert('Sonarr webhook test sent successfully!'); - } else { - alert('Sofarr webhook not configured for Sonarr.'); - } - } catch (err) { - console.error('Failed to test Sonarr webhook:', err); - alert('Failed to test Sonarr webhook. Check console for details.'); - } finally { - setWebhookLoading(false); - } - }; - - const testRadarrWebhook = async () => { - setWebhookLoading(true); - try { - const radarrResponse = await axios.get('/api/radarr/notifications'); - const radarrSofarr = radarrResponse.data.find(n => n.name === 'Sofarr'); - if (radarrSofarr) { - await axios.post('/api/radarr/notifications/test', { id: radarrSofarr.id }); - await fetchWebhookStatus(); - alert('Radarr webhook test sent successfully!'); - } else { - alert('Sofarr webhook not configured for Radarr.'); - } - } catch (err) { - console.error('Failed to test Radarr webhook:', err); - alert('Failed to test Radarr webhook. Check console for details.'); - } finally { - setWebhookLoading(false); - } - }; - - return ( -
-
-

Media Download Dashboard

- {currentUser && ( -
- Current User: - {currentUser} -
- )} -
- -
- - - -
- - {error && ( -
- {error} -
- )} - - {loading && ( -
Loading downloads...
- )} - - {!loading && !error && ( -
-

Your Downloads

- {downloads.length === 0 ? ( -
-

No downloads found for your user.

-

Make sure your shows and movies are tagged with "user:yourusername" in Sonarr/Radarr.

-
- ) : ( -
- {downloads.map((download, index) => ( -
- {download.coverArt && ( -
- {download.movieName -
- )} -
-
- - {download.type === 'series' ? 'πŸ“Ί Series' : '🎬 Movie'} - - - {download.status} - -
-

{download.title}

- {download.seriesName && ( -

Series: {download.seriesName}

- )} - {download.movieName && ( -

Movie: {download.movieName}

- )} -
-
- Size: - {formatSize(download.size)} -
- {download.progress && ( -
- Progress: - {download.progress}% -
- )} - {download.speed && ( -
- Speed: - {download.speed} -
- )} - {download.eta && ( -
- ETA: - {download.eta} -
- )} - {download.completedAt && ( -
- Completed: - {formatDate(download.completedAt)} -
- )} -
-
-
- ))} -
- )} -
- )} - -
-
setWebhookSectionExpanded(!webhookSectionExpanded)}> -

⚑ Webhooks Configuration

- β–Ό -
- {webhookSectionExpanded && ( -
- {webhookLoading &&
Loading webhook status...
} -
-

Sonarr

-
- - {sonarrWebhook.enabled ? '● Enabled' : 'β—‹ Disabled'} - - {!sonarrWebhook.enabled && ( - - )} - {sonarrWebhook.enabled && ( - - )} -
- {sonarrWebhook.enabled && ( -
-
- On Grab - - {sonarrWebhook.triggers.onGrab ? 'βœ“' : 'βœ—'} - -
-
- On Download - - {sonarrWebhook.triggers.onDownload ? 'βœ“' : 'βœ—'} - -
-
- On Import - - {sonarrWebhook.triggers.onImport ? 'βœ“' : 'βœ—'} - -
-
- On Upgrade - - {sonarrWebhook.triggers.onUpgrade ? 'βœ“' : 'βœ—'} - -
-
- )} - {sonarrWebhook.stats && ( -
-
Statistics
-
-
- Events Received - {sonarrWebhook.stats.eventsReceived ?? 0} -
-
- Polls Skipped - {sonarrWebhook.stats.pollsSkipped ?? 0} -
-
- Last Event - {formatTimeAgo(sonarrWebhook.stats.lastWebhookTimestamp)} -
-
-
- )} -
-
-

Radarr

-
- - {radarrWebhook.enabled ? '● Enabled' : 'β—‹ Disabled'} - - {!radarrWebhook.enabled && ( - - )} - {radarrWebhook.enabled && ( - - )} -
- {radarrWebhook.enabled && ( -
-
- On Grab - - {radarrWebhook.triggers.onGrab ? 'βœ“' : 'βœ—'} - -
-
- On Download - - {radarrWebhook.triggers.onDownload ? 'βœ“' : 'βœ—'} - -
-
- On Import - - {radarrWebhook.triggers.onImport ? 'βœ“' : 'βœ—'} - -
-
- On Upgrade - - {radarrWebhook.triggers.onUpgrade ? 'βœ“' : 'βœ—'} - -
-
- )} - {radarrWebhook.stats && ( -
-
Statistics
-
-
- Events Received - {radarrWebhook.stats.eventsReceived ?? 0} -
-
- Polls Skipped - {radarrWebhook.stats.pollsSkipped ?? 0} -
-
- Last Event - {formatTimeAgo(radarrWebhook.stats.lastWebhookTimestamp)} -
-
-
- )} -
-
- )} -
- -
-

Ensure your media is tagged with "user:username" in Sonarr/Radarr to match downloads to users.

-
-
- ); -} - -export default App; diff --git a/client/src/api.js b/client/src/api.js new file mode 100644 index 0000000..1a1f7d2 --- /dev/null +++ b/client/src/api.js @@ -0,0 +1,292 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. + +import { state } from './state.js'; + +export async function checkAuthentication() { + try { + // Fetch both auth state and a fresh CSRF token in parallel + const [meRes, csrfRes] = await Promise.all([ + fetch('/api/auth/me'), + fetch('/api/auth/csrf') + ]); + const data = await meRes.json(); + const csrfData = await csrfRes.json(); + if (csrfData.csrfToken) state.csrfToken = csrfData.csrfToken; + + if (data.authenticated) { + state.currentUser = data.user; + state.isAdmin = !!data.user.isAdmin; + return { authenticated: true, user: data.user }; + } else { + return { authenticated: false }; + } + } catch (err) { + console.error('Authentication check failed:', err); + return { authenticated: false }; + } +} + +export async function handleLogin(username, password, rememberMe) { + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ username, password, rememberMe }) + }); + + const data = await response.json(); + + if (data.success) { + state.currentUser = data.user; + state.isAdmin = !!data.user.isAdmin; + // Store CSRF token returned by login for use in subsequent requests + if (data.csrfToken) state.csrfToken = data.csrfToken; + return { success: true, user: data.user }; + } else { + return { success: false, error: data.error || 'Login failed' }; + } + } catch (err) { + console.error(err); + return { success: false, error: 'Login failed. Please try again.' }; + } +} + +export async function handleLogout() { + try { + await fetch('/api/auth/logout', { + method: 'POST', + headers: csrfToken ? { 'X-CSRF-Token': csrfToken } : {} + }); + currentUser = null; + csrfToken = null; + return { success: true }; + } catch (err) { + console.error('Logout failed:', err); + return { success: false }; + } +} + +export async function loadHistory(forceRefresh = false) { + try { + const params = new URLSearchParams({ days: state.historyDays }); + if (state.showAll) params.set('showAll', 'true'); + if (forceRefresh) params.set('_t', Date.now()); + const res = await fetch(`/api/history/recent?${params}`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + return { success: true, history: data.history || [] }; + } catch (err) { + console.error('[History] Load error:', err); + return { success: false, error: 'Failed to load history.' }; + } +} + +export async function handleBlocklistSearch(download) { + try { + const res = await fetch('/api/dashboard/blocklist-search', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken + }, + body: JSON.stringify({ + arrQueueId: download.arrQueueId, + arrType: download.arrType, + arrInstanceUrl: download.arrInstanceUrl, + arrInstanceKey: download.arrInstanceKey, + arrContentId: download.arrContentId, + arrContentType: download.arrContentType + }) + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `HTTP ${res.status}`); + } + + return { success: true }; + } catch (err) { + console.error('[Blocklist] Error:', err); + throw err; + } +} + +export async function loadAppVersion() { + try { + const res = await fetch('/health'); + const data = await res.json(); + return data.version || null; + } catch (err) { + return null; + } +} + +export async function fetchWebhookMetrics() { + try { + const res = await fetch('/api/dashboard/webhook-metrics'); + if (!res.ok) return null; + return await res.json(); + } catch (err) { + return null; + } +} + +export async function fetchWebhookStatus() { + try { + // Fetch metrics in parallel + const metricsPromise = fetchWebhookMetrics(); + + // Fetch Sonarr notifications + let sonarrEnabled = false; + let sonarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }; + try { + const sonarrRes = await fetch('/api/sonarr/notifications'); + if (sonarrRes.ok) { + const sonarrData = await sonarrRes.json(); + const sonarrSofarr = sonarrData.find(n => n.name === 'Sofarr'); + sonarrEnabled = !!sonarrSofarr; + if (sonarrSofarr) { + sonarrTriggers = { + onGrab: sonarrSofarr.onGrab, + onDownload: sonarrSofarr.onDownload, + onImport: sonarrSofarr.onImport, + onUpgrade: sonarrSofarr.onUpgrade + }; + } + } + } catch (err) { + // Sonarr not configured + } + + // Fetch Radarr notifications + let radarrEnabled = false; + let radarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }; + try { + const radarrRes = await fetch('/api/radarr/notifications'); + if (radarrRes.ok) { + const radarrData = await radarrRes.json(); + const radarrSofarr = radarrData.find(n => n.name === 'Sofarr'); + radarrEnabled = !!radarrSofarr; + if (radarrSofarr) { + radarrTriggers = { + onGrab: radarrSofarr.onGrab, + onDownload: radarrSofarr.onDownload, + onImport: radarrSofarr.onImport, + onUpgrade: radarrSofarr.onUpgrade + }; + } + } + } catch (err) { + // Radarr not configured + } + + state.webhookMetrics = await metricsPromise; + + // Find instance stats + const instanceEntries = state.webhookMetrics ? Object.entries(state.webhookMetrics.instances || {}) : []; + const sonarrStats = instanceEntries.find(([url]) => url.includes('sonarr'))?.[1] || null; + const radarrStats = instanceEntries.find(([url]) => url.includes('radarr'))?.[1] || null; + + state.sonarrWebhook = { enabled: sonarrEnabled, triggers: sonarrTriggers, stats: sonarrStats }; + state.radarrWebhook = { enabled: radarrEnabled, triggers: radarrTriggers, stats: radarrStats }; + + return { success: true }; + } catch (err) { + console.error('Failed to fetch webhook status:', err); + return { success: false }; + } +} + +export async function enableSonarrWebhook() { + try { + const res = await fetch('/api/sonarr/notifications/sofarr-webhook', { + method: 'POST', + headers: { 'X-CSRF-Token': csrfToken || '' } + }); + if (!res.ok) throw new Error('Failed to enable'); + await fetchWebhookStatus(); + return { success: true }; + } catch (err) { + console.error('Failed to enable Sonarr webhook:', err); + return { success: false, error: err.message }; + } +} + +export async function enableRadarrWebhook() { + try { + const res = await fetch('/api/radarr/notifications/sofarr-webhook', { + 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 Radarr webhook:', err); + return { success: false, error: err.message }; + } +} + +export async function testSonarrWebhook() { + try { + const sonarrRes = await fetch('/api/sonarr/notifications'); + if (!sonarrRes.ok) throw new Error('Failed to fetch notifications'); + const sonarrData = await sonarrRes.json(); + const sonarrSofarr = sonarrData.find(n => n.name === 'Sofarr'); + if (!sonarrSofarr) throw new Error('Sofarr webhook not found'); + + const res = await fetch('/api/sonarr/notifications/test', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': state.csrfToken || '' + }, + body: JSON.stringify(sonarrSofarr) + }); + if (!res.ok) throw new Error('Test failed'); + await fetchWebhookStatus(); + return { success: true }; + } catch (err) { + console.error('Failed to test Sonarr webhook:', err); + return { success: false, error: err.message }; + } +} + +export async function testRadarrWebhook() { + try { + const radarrRes = await fetch('/api/radarr/notifications'); + if (!radarrRes.ok) throw new Error('Failed to fetch notifications'); + const radarrData = await radarrRes.json(); + const radarrSofarr = radarrData.find(n => n.name === 'Sofarr'); + if (!radarrSofarr) throw new Error('Sofarr webhook not found'); + + const res = await fetch('/api/radarr/notifications/test', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': state.csrfToken || '' + }, + body: JSON.stringify(radarrSofarr) + }); + if (!res.ok) throw new Error('Test failed'); + await fetchWebhookStatus(); + return { success: true }; + } catch (err) { + console.error('Failed to test Radarr webhook:', err); + return { success: false, error: err.message }; + } +} + +export async function refreshStatusPanel() { + try { + const res = await fetch('/api/dashboard/status'); + if (!res.ok) throw new Error('Failed to fetch status: ' + res.status); + const data = await res.json(); + return { success: true, data }; + } catch (err) { + console.error('[Status] Error fetching status:', err); + return { success: false, error: err.message }; + } +} diff --git a/client/src/main.js b/client/src/main.js new file mode 100644 index 0000000..d53fa0a --- /dev/null +++ b/client/src/main.js @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. + +// Bootstrap - wire all event handlers on DOMContentLoaded +import { checkAuthenticationAndInit, handleLogin, handleLogoutClick } from './ui/auth.js'; +import { initDownloadClientFilter } from './ui/filters.js'; +import { initHistoryControls } from './ui/history.js'; +import { toggleStatusPanel } from './ui/statusPanel.js'; +import { initWebhooks } from './ui/webhooks.js'; +import { initThemeSwitcher } from './ui/theme.js'; +import { initTabs, goHome } from './ui/tabs.js'; +import { handleShowAllToggle } from './sse.js'; +import { loadAppVersion } from './api.js'; + +document.addEventListener('DOMContentLoaded', () => { + // Login form + const loginForm = document.getElementById('login-form'); + if (loginForm) { + loginForm.addEventListener('submit', handleLogin); + } + + // Logout button + const logoutBtn = document.getElementById('logout-btn'); + if (logoutBtn) { + logoutBtn.addEventListener('click', handleLogoutClick); + } + + // Show all toggle + const showAllToggle = document.getElementById('show-all-toggle'); + if (showAllToggle) { + showAllToggle.addEventListener('change', (e) => handleShowAllToggle(e.target.checked)); + } + + // Status panel toggle + const statusToggle = document.getElementById('status-toggle'); + if (statusToggle) { + statusToggle.addEventListener('click', toggleStatusPanel); + } + + // Home button + const homeBtn = document.getElementById('home-btn'); + if (homeBtn) { + homeBtn.addEventListener('click', goHome); + } + + // Initialize UI components + initThemeSwitcher(); + initTabs(); + initDownloadClientFilter(); + initHistoryControls(); + initWebhooks(); + + // Load app version + loadAppVersion().then(version => { + const versionEl = document.getElementById('app-version'); + if (versionEl && version) { + versionEl.textContent = 'v' + version; + } + }); + + // Check authentication and initialize + checkAuthenticationAndInit(); +}); diff --git a/client/src/main.jsx b/client/src/main.jsx deleted file mode 100644 index 93ce542..0000000 --- a/client/src/main.jsx +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) 2026 Gordon Bolton. MIT License. -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.jsx' -import './App.css' - -ReactDOM.createRoot(document.getElementById('root')).render( - - - , -) diff --git a/client/src/sse.js b/client/src/sse.js new file mode 100644 index 0000000..1b505d9 --- /dev/null +++ b/client/src/sse.js @@ -0,0 +1,60 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. + +import { state, SSE_RECONNECT_MS } from './state.js'; +import { renderDownloads } from './ui/downloads.js'; +import { hideError, hideLoading } from './ui/auth.js'; + +export function startSSE() { + stopSSE(); + const params = state.showAll ? '?showAll=true' : ''; + const source = new EventSource('/api/dashboard/stream' + params); + state.sseSource = source; + + let firstMessage = true; + source.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + state.currentUser = data.user; + state.isAdmin = !!data.isAdmin; + state.downloads = data.downloads; + // Store download clients and update filter dropdown + if (data.downloadClients) { + state.downloadClients = data.downloadClients; + // Trigger filter update + const filterUpdateEvent = new CustomEvent('downloadClientsUpdated'); + document.dispatchEvent(filterUpdateEvent); + } + document.getElementById('currentUser').textContent = state.currentUser || '-'; + renderDownloads(); + hideError(); + if (firstMessage) { firstMessage = false; hideLoading(); } + } catch (err) { + console.error('[SSE] Failed to parse message:', err); + } + }; + + source.onerror = () => { + // EventSource retries automatically; we just log and show a reconnecting indicator + console.warn('[SSE] Connection lost, browser will retry...'); + }; + + console.log('[SSE] Stream connected'); +} + +export function stopSSE() { + if (state.sseReconnectTimer) { clearTimeout(state.sseReconnectTimer); state.sseReconnectTimer = null; } + if (state.sseSource) { + state.sseSource.close(); + state.sseSource = null; + console.log('[SSE] Stream closed'); + } +} + +export function handleShowAllToggle(checked) { + state.showAll = checked; + // Re-open stream with updated showAll param + startSSE(); + // Trigger history reload with updated showAll param + const historyReloadEvent = new CustomEvent('historyReload'); + document.dispatchEvent(historyReloadEvent); +} diff --git a/client/src/state.js b/client/src/state.js new file mode 100644 index 0000000..a3a1d5f --- /dev/null +++ b/client/src/state.js @@ -0,0 +1,38 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. + +// Global state (using objects for mutability across modules) +export const state = { + currentUser: null, + downloads: [], + downloadClients: [], // List of download clients from server (for ordering/filtering) + selectedDownloadClients: [], // Array of selected client IDs for multi-select filter + isAdmin: false, + showAll: false, + csrfToken: null, // double-submit CSRF token, sent as X-CSRF-Token on mutating requests + + // History section state + historyDays: 7, // Default value, will be loaded from localStorage + historyRefreshHandle: null, + ignoreAvailable: false, // Default value, will be loaded from localStorage + lastHistoryItems: [], // raw items from last fetch, for re-filtering without a network round-trip + + // SSE stream state + sseSource: null, + sseReconnectTimer: null, + + // Status panel state + statusRefreshHandle: null, + + // Webhooks state + webhookSectionExpanded: false, + 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 }, + webhookMetrics: null +}; + +// Constants +export const SPLASH_MIN_MS = 1200; // minimum splash display time +export const HISTORY_REFRESH_MS = 5 * 60 * 1000; // auto-refresh history every 5 min +export const SSE_RECONNECT_MS = 3000; // browser already retries, but we add explicit backoff too +export const STATUS_REFRESH_MS = 5000; diff --git a/client/src/ui/auth.js b/client/src/ui/auth.js new file mode 100644 index 0000000..66c0c2f --- /dev/null +++ b/client/src/ui/auth.js @@ -0,0 +1,171 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. + +import { state, SPLASH_MIN_MS } from '../state.js'; +import { checkAuthentication, handleLogin as apiHandleLogin, handleLogout as apiHandleLogout } from '../api.js'; +import { startSSE, stopSSE } from '../sse.js'; +import { stopHistoryRefresh, clearHistory, startHistoryRefresh } from './history.js'; +import { closeStatusPanel } from './statusPanel.js'; + +export function fadeOutLogin() { + return new Promise(resolve => { + const login = document.getElementById('login-container'); + login.classList.add('fade-out'); + login.addEventListener('transitionend', () => { + login.style.display = 'none'; + login.classList.remove('fade-out'); + resolve(); + }, { once: true }); + }); +} + +export function showSplash() { + const splash = document.getElementById('splash-screen'); + splash.style.display = 'flex'; + splash.style.opacity = '1'; + splash.classList.remove('fade-out'); +} + +export function dismissSplash(startTime) { + return new Promise(resolve => { + const elapsed = Date.now() - (startTime || 0); + const remaining = Math.max(0, SPLASH_MIN_MS - elapsed); + setTimeout(() => { + const splash = document.getElementById('splash-screen'); + splash.classList.add('fade-out'); + // Fallback: resolve after transition duration + buffer in case + // transitionend never fires (e.g. display was toggled in same frame) + const TRANSITION_MS = 400; + const fallback = setTimeout(() => { + splash.style.display = 'none'; + resolve(); + }, TRANSITION_MS + 100); + splash.addEventListener('transitionend', () => { + clearTimeout(fallback); + splash.style.display = 'none'; + resolve(); + }, { once: true }); + }, remaining); + }); +} + +export async function checkAuthenticationAndInit() { + const splashStart = Date.now(); + try { + const result = await checkAuthentication(); + if (result.authenticated) { + showDashboard(); + showLoading(); + startSSE(); + await dismissSplash(splashStart); + } else { + await dismissSplash(splashStart); + showLogin(); + } + } catch (err) { + console.error('Authentication check failed:', err); + await dismissSplash(splashStart); + showLogin(); + } +} + +export async function handleLogin(e) { + e.preventDefault(); + + const username = document.getElementById('username').value; + const password = document.getElementById('password').value; + const rememberMe = document.getElementById('remember-me').checked; + + try { + const result = await apiHandleLogin(username, password, rememberMe); + + if (result.success) { + // Fade out login, then show splash while opening SSE stream. + // requestAnimationFrame ensures the browser paints the splash at + // opacity:1 before dismissSplash adds fade-out, so the CSS + // transition fires and transitionend is guaranteed. + await fadeOutLogin(); + showSplash(); + await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); + showDashboard(); + showLoading(); + const splashStart = Date.now(); + startSSE(); + await dismissSplash(splashStart); + } else { + showLoginError(result.error || 'Login failed'); + } + } catch (err) { + showLoginError('Login failed. Please try again.'); + console.error(err); + } +} + +export async function handleLogoutClick() { + try { + stopSSE(); + stopHistoryRefresh(); + if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle.value = null; } + await apiHandleLogout(); + currentUser = null; + clearHistory(); + showLogin(); + } catch (err) { + console.error('Logout failed:', err); + } +} + +export function showLogin() { + document.getElementById('login-container').style.display = 'flex'; + document.getElementById('dashboard-container').style.display = 'none'; + hideLoginError(); +} + +export function showDashboard() { + document.getElementById('login-container').style.display = 'none'; + document.getElementById('dashboard-container').style.display = 'block'; + document.getElementById('currentUser').textContent = state.currentUser.name || '-'; + // Always start with status panel hidden (guards against stale display value on re-login) + const sp = document.getElementById('status-panel'); + sp.style.display = 'none'; + // Also hide webhooks-section to keep them in sync (both show/hide together) + const webhooksSection = document.getElementById('webhooks-section'); + if (webhooksSection) webhooksSection.style.display = 'none'; + document.getElementById('admin-controls').style.display = state.isAdmin ? 'flex' : 'none'; + // Note: webhooks-section visibility is controlled by toggleStatusPanel() + // Initialise days input from saved value + const daysInput = document.getElementById('history-days'); + if (daysInput) daysInput.value = state.historyDays; + startHistoryRefresh(); +} + +export function showLoginError(message) { + const errorDiv = document.getElementById('login-error'); + errorDiv.textContent = message; + errorDiv.style.display = 'block'; +} + +export function hideLoginError() { + const errorDiv = document.getElementById('login-error'); + errorDiv.style.display = 'none'; +} + +export function showError(message) { + const errorDiv = document.getElementById('error-message'); + errorDiv.textContent = message; + errorDiv.style.display = 'block'; +} + +export function hideError() { + const errorDiv = document.getElementById('error-message'); + errorDiv.style.display = 'none'; +} + +export function showLoading() { + const loading = document.getElementById('loading'); + loading.style.display = 'block'; +} + +export function hideLoading() { + const loading = document.getElementById('loading'); + loading.style.display = 'none'; +} diff --git a/client/src/ui/downloads.js b/client/src/ui/downloads.js new file mode 100644 index 0000000..d3488ed --- /dev/null +++ b/client/src/ui/downloads.js @@ -0,0 +1,566 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. + +import { state } from '../state.js'; +import { formatSize, formatSpeed, formatEpisodeInfo, escapeHtml } from '../utils/format.js'; +import { handleBlocklistSearch } from '../api.js'; + +export function renderDownloads() { + const downloadsList = document.getElementById('downloads-list'); + const noDownloads = document.getElementById('no-downloads'); + + // Filter downloads by selected clients + let filteredDownloads = state.downloads; + if (state.selectedDownloadClients.length > 0) { + // Map indices to client objects, then filter by both client type and instanceId + const selectedClients = state.selectedDownloadClients.map(idx => state.downloadClients[idx]).filter(Boolean); + filteredDownloads = state.downloads.filter(d => + selectedClients.some(c => c.type === d.client && c.id === d.instanceId) + ); + } + + // Sort downloads by client order (matching the order in downloadClients) + if (state.downloadClients.length > 0) { + const clientOrder = new Map(state.downloadClients.map((c, idx) => [c.id, idx])); + filteredDownloads = [...filteredDownloads].sort((a, b) => { + const orderA = clientOrder.get(a.instanceId) ?? Infinity; + const orderB = clientOrder.get(b.instanceId) ?? Infinity; + return orderA - orderB; + }); + } + + if (filteredDownloads.length === 0) { + noDownloads.style.display = 'block'; + downloadsList.innerHTML = ''; + return; + } + + noDownloads.style.display = 'none'; + + // Get existing cards + const existingCards = new Map(); + downloadsList.querySelectorAll('.download-card').forEach(card => { + existingCards.set(card.dataset.id, card); + }); + + // Track which downloads we've processed + const processedIds = new Set(); + + filteredDownloads.forEach(download => { + const id = download.title; + processedIds.add(id); + + const existingCard = existingCards.get(id); + if (existingCard) { + // Update existing card + updateDownloadCard(existingCard, download); + } else { + // Create new card + const card = createDownloadCard(download); + downloadsList.appendChild(card); + } + }); + + // Remove cards for downloads that no longer exist + existingCards.forEach((card, id) => { + if (!processedIds.has(id)) { + card.remove(); + } + }); +} + +export function updateDownloadCard(card, download) { + // Remove old header-right container if it exists + const oldRightSide = card.querySelector('.download-header-right'); + if (oldRightSide) { + oldRightSide.remove(); + } + + // Remove old user badges directly in header + const oldBadges = card.querySelectorAll('.download-header .download-user-badge'); + oldBadges.forEach(badge => badge.remove()); + + // Remove old client logo from header (old structure) + const oldLogoInHeader = card.querySelector('.download-header .download-client-logo-wrapper'); + if (oldLogoInHeader) { + oldLogoInHeader.remove(); + } + + // Remove old client logo from card (new structure) if it exists + const oldLogoInCard = card.querySelector('.download-card-logo-wrapper'); + if (oldLogoInCard) { + oldLogoInCard.remove(); + } + + // Add new right-side container with user badge only + const header = card.querySelector('.download-header'); + if (header && !header.querySelector('.download-header-right')) { + const rightSide = document.createElement('div'); + rightSide.className = 'download-header-right'; + + if (state.showAll && download.tagBadges && download.tagBadges.length > 0) { + const unmatched = download.tagBadges.filter(b => !b.matchedUser); + const matched = download.tagBadges.filter(b => b.matchedUser); + for (const b of unmatched) { + const badge = document.createElement('span'); + badge.className = 'download-user-badge unmatched'; + badge.textContent = b.label; + rightSide.appendChild(badge); + } + for (const b of matched) { + const badge = document.createElement('span'); + badge.className = 'download-user-badge'; + badge.textContent = b.matchedUser; + rightSide.appendChild(badge); + } + } else if (download.matchedUserTag) { + const matchedBadge = document.createElement('span'); + matchedBadge.className = 'download-user-badge'; + matchedBadge.textContent = download.matchedUserTag; + rightSide.appendChild(matchedBadge); + } + + header.appendChild(rightSide); + } + + // Add client logo to card (positioned at bottom right via CSS) + if (download.client && !card.querySelector('.download-card-logo-wrapper')) { + const clientLogoWrapper = document.createElement('span'); + clientLogoWrapper.className = 'download-client-logo-wrapper download-card-logo-wrapper'; + + const clientLogo = document.createElement('img'); + clientLogo.className = 'download-client-logo'; + clientLogo.src = `/images/clients/${download.client}.svg`; + clientLogo.alt = `${download.instanceName || download.client} icon`; + clientLogo.title = download.instanceName || download.client; + clientLogo.onerror = () => { + clientLogoWrapper.textContent = download.client.charAt(0).toUpperCase(); + clientLogoWrapper.classList.add('fallback'); + }; + + clientLogoWrapper.appendChild(clientLogo); + card.appendChild(clientLogoWrapper); + } + + // Update status + const statusEl = card.querySelector('.download-status'); + if (statusEl && statusEl.textContent !== download.status) { + statusEl.textContent = download.status; + statusEl.className = `download-status ${download.status}`; + } + + // Update progress bar and missing pieces + const progressContainer = card.querySelector('.progress-container'); + if (progressContainer && download.progress !== undefined) { + const progressBar = progressContainer.querySelector('.progress-bar'); + const progressText = progressContainer.querySelector('.progress-text'); + const missingText = progressContainer.querySelector('.missing-text'); + + if (progressBar) { + const downloaded = progressBar.querySelector('.downloaded'); + if (downloaded) { + downloaded.style.width = download.progress + '%'; + } + } + + if (progressText) { + progressText.textContent = download.progress + '%'; + } + + if (missingText) { + const totalMb = parseFloat(download.mb) || parseFloat(download.size); + const missingMb = parseFloat(download.mbmissing) || 0; + if (missingMb > 0 && totalMb > 0) { + missingText.textContent = `(missing ${missingMb.toFixed(1)} of ${totalMb.toFixed(1)} MB)`; + } else { + missingText.textContent = ''; + } + } + } + + // Update speed + const speedEl = card.querySelector('.detail-item[data-label="Speed"] .detail-value'); + if (speedEl && download.speed !== undefined) { + speedEl.textContent = download.speed; + } + + // Update ETA + const etaEl = card.querySelector('.detail-item[data-label="ETA"] .detail-value'); + if (etaEl && download.eta !== undefined) { + etaEl.textContent = download.eta; + } + + // Update qBittorrent-specific fields + if (download.qbittorrent) { + const seedsEl = card.querySelector('.detail-item[data-label="Seeds"] .detail-value'); + if (seedsEl && download.seeds !== undefined) { + seedsEl.textContent = download.seeds; + } + + const peersEl = card.querySelector('.detail-item[data-label="Peers"] .detail-value'); + if (peersEl && download.peers !== undefined) { + peersEl.textContent = download.peers; + } + + const availabilityItem = card.querySelector('.detail-item[data-label="Availability"]'); + if (availabilityItem && download.availability !== undefined) { + availabilityItem.querySelector('.detail-value').textContent = `${download.availability}%`; + availabilityItem.classList.toggle('availability-warning', parseFloat(download.availability) < 100); + } + } +} + +export async function handleBlocklistSearchClick(btn, download) { + if (!confirm(`Blocklist "${download.title}" and trigger a new search?\n\nThis will:\nβ€’ Remove the download from the download client\nβ€’ Add this release to the blocklist\nβ€’ Trigger an automatic search for a new release`)) return; + + btn.disabled = true; + btn.textContent = '⏳ Working…'; + + try { + await handleBlocklistSearch(download); + btn.textContent = 'βœ“ Done β€” searching…'; + btn.className = 'blocklist-search-btn success'; + } catch (err) { + console.error('[Blocklist] Error:', err); + btn.disabled = false; + btn.textContent = 'β›” Blocklist & Search'; + btn.className = 'blocklist-search-btn error'; + btn.title = `Failed: ${err.message}`; + setTimeout(() => { + btn.className = 'blocklist-search-btn'; + btn.title = 'Remove this release from the download client, add it to the blocklist, and trigger a new automatic search'; + }, 4000); + } +} + +export function createDownloadCard(download) { + const card = document.createElement('div'); + card.className = `download-card ${download.type}`; + card.dataset.id = download.title; + + // Cover art + if (download.coverArt) { + const coverDiv = document.createElement('div'); + coverDiv.className = 'download-cover'; + const coverImg = document.createElement('img'); + // Proxy cover art through the server so the CSP img-src 'self' rule + // is satisfied (external poster URLs would be blocked otherwise). + coverImg.src = download.coverArt + ? '/api/dashboard/cover-art?url=' + encodeURIComponent(download.coverArt) + : ''; + coverImg.alt = download.movieName || download.seriesName || download.title; + coverImg.loading = 'lazy'; + coverDiv.appendChild(coverImg); + card.appendChild(coverDiv); + } + + // Info wrapper + const infoDiv = document.createElement('div'); + infoDiv.className = 'download-info'; + + const header = document.createElement('div'); + header.className = 'download-header'; + + const type = document.createElement('span'); + type.className = `download-type ${download.type}`; + if (download.type === 'series') { + type.textContent = 'πŸ“Ί Series'; + } else if (download.type === 'movie') { + type.textContent = '🎬 Movie'; + } else if (download.type === 'torrent') { + const instName = download.instanceName ? ` (${download.instanceName})` : ''; + type.textContent = `πŸ“₯ Torrent${instName}`; + } else { + type.textContent = download.type; + } + + const status = document.createElement('span'); + status.className = `download-status ${download.status}`; + status.textContent = download.status; + + header.appendChild(type); + header.appendChild(status); + + if (download.importIssues && download.importIssues.length > 0) { + const issueBadge = document.createElement('span'); + issueBadge.className = 'import-issue-badge'; + issueBadge.textContent = 'Import Pending'; + issueBadge.setAttribute('data-tooltip', download.importIssues.join('\n')); + header.appendChild(issueBadge); + } + + if ((state.isAdmin || download.canBlocklist) && download.arrQueueId) { + const blBtn = document.createElement('button'); + blBtn.className = 'blocklist-search-btn'; + blBtn.textContent = 'β›” Blocklist & Search'; + blBtn.title = 'Remove this release from the download client, add it to the blocklist, and trigger a new automatic search'; + blBtn.addEventListener('click', () => handleBlocklistSearchClick(blBtn, download)); + header.appendChild(blBtn); + } + + // Right side container for user badge only + const rightSide = document.createElement('div'); + rightSide.className = 'download-header-right'; + + if (state.showAll && download.tagBadges && download.tagBadges.length > 0) { + // In showAll mode: render all tags classified by whether they match an Emby user. + // Unmatched (no known Emby user) β†’ amber, leftmost. + // Matched β†’ show Emby display name in accent colour, rightmost. + const unmatched = download.tagBadges.filter(b => !b.matchedUser); + const matched = download.tagBadges.filter(b => b.matchedUser); + for (const b of unmatched) { + const badge = document.createElement('span'); + badge.className = 'download-user-badge unmatched'; + badge.textContent = b.label; + rightSide.appendChild(badge); + } + for (const b of matched) { + const badge = document.createElement('span'); + badge.className = 'download-user-badge'; + badge.textContent = b.matchedUser; + rightSide.appendChild(badge); + } + } else if (download.matchedUserTag) { + // Normal (non-showAll) view: show only the current user's matched tag + const matchedBadge = document.createElement('span'); + matchedBadge.className = 'download-user-badge'; + matchedBadge.textContent = download.matchedUserTag; + rightSide.appendChild(matchedBadge); + } + + header.appendChild(rightSide); + + // Add client logo to card (positioned at bottom right via CSS) + if (download.client) { + const clientLogoWrapper = document.createElement('span'); + clientLogoWrapper.className = 'download-client-logo-wrapper download-card-logo-wrapper'; + + const clientLogo = document.createElement('img'); + clientLogo.className = 'download-client-logo'; + clientLogo.src = `/images/clients/${download.client}.svg`; + clientLogo.alt = `${download.instanceName || download.client} icon`; + clientLogo.title = download.instanceName || download.client; + clientLogo.onerror = () => { + // Fallback to text if image fails to load + clientLogoWrapper.textContent = download.client.charAt(0).toUpperCase(); + clientLogoWrapper.classList.add('fallback'); + }; + + clientLogoWrapper.appendChild(clientLogo); + card.appendChild(clientLogoWrapper); + } + + const title = document.createElement('h3'); + title.className = 'download-title'; + title.textContent = download.title; + + infoDiv.appendChild(header); + infoDiv.appendChild(title); + + if (download.seriesName) { + const series = document.createElement('p'); + series.className = 'download-series'; + if (state.isAdmin && download.arrLink) { + series.innerHTML = 'Series: ' + escapeHtml(download.seriesName) + ''; + } else { + series.textContent = `Series: ${download.seriesName}`; + } + infoDiv.appendChild(series); + const epEl = formatEpisodeInfo(download.episodes); + if (epEl) infoDiv.appendChild(epEl); + } + + if (download.movieName) { + const movie = document.createElement('p'); + movie.className = 'download-movie'; + if (state.isAdmin && download.arrLink) { + movie.innerHTML = 'Movie: ' + escapeHtml(download.movieName) + ''; + } else { + movie.textContent = `Movie: ${download.movieName}`; + } + infoDiv.appendChild(movie); + } + + if (state.showAll && download.tagBadges && download.tagBadges.length > 0) { + // In showAll mode: render all tags classified by whether they match an Emby user. + // Unmatched (no known Emby user) β†’ amber, leftmost. + // Matched β†’ show Emby display name in accent colour, rightmost. + const unmatched = download.tagBadges.filter(b => !b.matchedUser); + const matched = download.tagBadges.filter(b => b.matchedUser); + for (const b of unmatched) { + const badge = document.createElement('span'); + badge.className = 'download-user-badge unmatched'; + badge.textContent = b.label; + header.appendChild(badge); + } + for (const b of matched) { + const badge = document.createElement('span'); + badge.className = 'download-user-badge'; + badge.textContent = b.matchedUser; + header.appendChild(badge); + } + } else if (download.matchedUserTag) { + // Normal (non-showAll) view: show only the current user's matched tag + const matchedBadge = document.createElement('span'); + matchedBadge.className = 'download-user-badge'; + matchedBadge.textContent = download.matchedUserTag; + header.appendChild(matchedBadge); + } + + // Add client logo + if (download.client) { + const clientLogoWrapper = document.createElement('span'); + clientLogoWrapper.className = 'download-client-logo-wrapper download-card-logo-wrapper'; + + const clientLogo = document.createElement('img'); + clientLogo.className = 'download-client-logo'; + clientLogo.src = `/images/clients/${download.client}.svg`; + clientLogo.alt = `${download.instanceName || download.client} icon`; + clientLogo.title = download.instanceName || download.client; + clientLogo.onerror = () => { + // Fallback to text if image fails to load + clientLogoWrapper.textContent = download.client.charAt(0).toUpperCase(); + clientLogoWrapper.classList.add('fallback'); + }; + + clientLogoWrapper.appendChild(clientLogo); + header.appendChild(clientLogoWrapper); + } + + const details = document.createElement('div'); + details.className = 'download-details'; + + const size = createDetailItem('Size', formatSize(download.size)); + details.appendChild(size); + + if (download.progress !== undefined) { + const progressItem = document.createElement('div'); + progressItem.className = 'detail-item progress-item'; + progressItem.dataset.label = 'Progress'; + + const labelSpan = document.createElement('span'); + labelSpan.className = 'detail-label'; + labelSpan.textContent = 'Progress'; + + const valueDiv = document.createElement('div'); + valueDiv.className = 'progress-container'; + + // Progress bar with segments + const totalMb = parseFloat(download.mb) || parseFloat(download.size); + const missingMb = parseFloat(download.mbmissing) || 0; + const downloadedMb = totalMb - missingMb; + const progressPercent = parseFloat(download.progress) || 0; + const missingPercent = totalMb > 0 ? (missingMb / totalMb) * 100 : 0; + + const progressBar = document.createElement('div'); + progressBar.className = 'progress-bar'; + + // Downloaded portion (green) + if (progressPercent > 0) { + const downloaded = document.createElement('div'); + downloaded.className = 'progress-segment downloaded'; + downloaded.style.width = progressPercent + '%'; + progressBar.appendChild(downloaded); + } + + valueDiv.appendChild(progressBar); + + // Text showing percentage + const progressText = document.createElement('span'); + progressText.className = 'progress-text'; + progressText.textContent = download.progress + '%'; + valueDiv.appendChild(progressText); + + // Missing pieces text (only for torrent clients like qBittorrent) + if (download.client && (download.client === 'qbittorrent' || download.client === 'rtorrent') && missingMb > 0 && totalMb > 0) { + const missingText = document.createElement('span'); + missingText.className = 'missing-text'; + missingText.textContent = `(missing ${missingMb.toFixed(1)} of ${totalMb.toFixed(1)} MB)`; + valueDiv.appendChild(missingText); + } + + progressItem.appendChild(labelSpan); + progressItem.appendChild(valueDiv); + details.appendChild(progressItem); + } + + if (download.speed && download.speed > 0) { + const speed = createDetailItem('Speed', formatSpeed(download.speed)); + details.appendChild(speed); + } + + if (download.eta) { + const eta = createDetailItem('ETA', download.eta); + details.appendChild(eta); + } + + // qBittorrent-specific fields + if (download.qbittorrent) { + if (download.seeds !== undefined) { + const seeds = createDetailItem('Seeds', download.seeds); + details.appendChild(seeds); + } + + if (download.peers !== undefined) { + const peers = createDetailItem('Peers', download.peers); + details.appendChild(peers); + } + + if (download.availability !== undefined) { + const availability = createDetailItem('Availability', `${download.availability}%`); + if (parseFloat(download.availability) < 100) availability.classList.add('availability-warning'); + details.appendChild(availability); + } + } + + if (download.completedAt) { + const completed = createDetailItem('Completed', formatDate(download.completedAt)); + details.appendChild(completed); + } + + if (state.isAdmin && (download.downloadPath || download.targetPath)) { + const pathsDiv = document.createElement('div'); + pathsDiv.className = 'download-paths'; + if (download.downloadPath) { + const dlPath = document.createElement('div'); + dlPath.className = 'path-item'; + dlPath.innerHTML = 'Download: ' + escapeHtml(download.downloadPath) + ''; + pathsDiv.appendChild(dlPath); + } + if (download.targetPath) { + const tgtPath = document.createElement('div'); + tgtPath.className = 'path-item'; + tgtPath.innerHTML = 'Target: ' + escapeHtml(download.targetPath) + ''; + pathsDiv.appendChild(tgtPath); + } + details.appendChild(pathsDiv); + } + + infoDiv.appendChild(details); + card.appendChild(infoDiv); + + return card; +} + +export function createDetailItem(label, value) { + const item = document.createElement('div'); + item.className = 'detail-item'; + item.dataset.label = label; + + const labelSpan = document.createElement('span'); + labelSpan.className = 'detail-label'; + labelSpan.textContent = label; + + const valueSpan = document.createElement('span'); + valueSpan.className = 'detail-value'; + valueSpan.textContent = value; + + item.appendChild(labelSpan); + item.appendChild(valueSpan); + + return item; +} + +function formatDate(dateString) { + if (!dateString) return 'N/A'; + return new Date(dateString).toLocaleString(); +} diff --git a/client/src/ui/filters.js b/client/src/ui/filters.js new file mode 100644 index 0000000..ddc1aa6 --- /dev/null +++ b/client/src/ui/filters.js @@ -0,0 +1,87 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. + +import { state } from '../state.js'; +import { saveDownloadClients } from '../utils/storage.js'; +import { renderDownloads } from './downloads.js'; + +export function initDownloadClientFilter() { + const filterBtn = document.getElementById('download-client-filter-btn'); + const filterDropdown = document.getElementById('download-client-filter-dropdown'); + const filterClose = document.getElementById('download-client-filter-close'); + + if (!filterBtn || !filterDropdown) return; + + filterBtn.addEventListener('click', (e) => { + e.stopPropagation(); + filterDropdown.style.display = filterDropdown.style.display === 'block' ? 'none' : 'block'; + }); + + filterClose.addEventListener('click', () => { + filterDropdown.style.display = 'none'; + }); + + // Close dropdown when clicking outside + document.addEventListener('click', (e) => { + if (!filterDropdown.contains(e.target) && e.target !== filterBtn) { + filterDropdown.style.display = 'none'; + } + }); + + // Listen for download clients updates from SSE + document.addEventListener('downloadClientsUpdated', updateDownloadClientFilter); + + // Initial filter update + updateDownloadClientFilter(); +} + +export function updateDownloadClientFilter() { + const filterList = document.getElementById('download-client-filter-list'); + if (!filterList) return; + + filterList.innerHTML = ''; + + state.downloadClients.forEach((client, index) => { + const item = document.createElement('div'); + item.className = 'filter-item'; + item.dataset.index = index; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.id = `client-${index}`; + checkbox.checked = state.selectedDownloadClients.includes(index); + checkbox.addEventListener('change', () => toggleClientSelection(index)); + + const label = document.createElement('label'); + label.htmlFor = `client-${index}`; + label.textContent = client.name || `${client.type} (${client.id})`; + + item.appendChild(checkbox); + item.appendChild(label); + filterList.appendChild(item); + }); + + updateSelectedCountDisplay(); +} + +export function toggleClientSelection(index) { + const idx = state.selectedDownloadClients.indexOf(index); + if (idx > -1) { + state.selectedDownloadClients.splice(idx, 1); + } else { + state.selectedDownloadClients.push(index); + } + saveDownloadClients(state.selectedDownloadClients); + updateSelectedCountDisplay(); + renderDownloads(); +} + +export function updateSelectedCountDisplay() { + const countDisplay = document.getElementById('download-client-filter-count'); + if (!countDisplay) return; + + if (state.selectedDownloadClients.length === 0) { + countDisplay.textContent = 'All'; + } else { + countDisplay.textContent = state.selectedDownloadClients.length; + } +} diff --git a/client/src/ui/history.js b/client/src/ui/history.js new file mode 100644 index 0000000..bb67df3 --- /dev/null +++ b/client/src/ui/history.js @@ -0,0 +1,244 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. + +import { state, HISTORY_REFRESH_MS } from '../state.js'; +import { loadHistory as apiLoadHistory } from '../api.js'; +import { saveHistoryDays, saveIgnoreAvailable } from '../utils/storage.js'; +import { formatDate, formatEpisodeInfo, escapeHtml } from '../utils/format.js'; + +export function initHistoryControls() { + const daysInput = document.getElementById('history-days'); + const refreshBtn = document.getElementById('history-refresh-btn'); + const ignoreToggle = document.getElementById('ignore-available-toggle'); + if (daysInput) { + daysInput.addEventListener('change', () => { + const v = parseInt(daysInput.value, 10); + if (v > 0 && v <= 90) { + historyDays = v; + saveHistoryDays(v); + loadHistory(true); + } + }); + } + if (refreshBtn) { + refreshBtn.addEventListener('click', () => loadHistory(true)); + } + if (ignoreToggle) { + ignoreToggle.checked = ignoreAvailable; + ignoreToggle.addEventListener('change', () => { + ignoreAvailable = ignoreToggle.checked; + saveIgnoreAvailable(ignoreAvailable); + renderHistory(lastHistoryItems); + }); + } + + // Listen for history reload events from other modules + document.addEventListener('historyReload', () => { + loadHistory(true); + }); +} + +export function startHistoryRefresh() { + stopHistoryRefresh(); + state.historyRefreshHandle = setInterval(() => loadHistory(), HISTORY_REFRESH_MS); +} + +export function stopHistoryRefresh() { + if (state.historyRefreshHandle) { + clearInterval(state.historyRefreshHandle); + state.historyRefreshHandle = null; + } +} + +export function clearHistory() { + state.lastHistoryItems = []; + document.getElementById('history-list').innerHTML = ''; + document.getElementById('no-history').style.display = 'none'; + document.getElementById('history-error').style.display = 'none'; +} + +export async function loadHistory(forceRefresh = false) { + const listEl = document.getElementById('history-list'); + const loadingEl = document.getElementById('history-loading'); + const errorEl = document.getElementById('history-error'); + const noHistoryEl = document.getElementById('no-history'); + + loadingEl.style.display = 'block'; + errorEl.style.display = 'none'; + noHistoryEl.style.display = 'none'; + + try { + const result = await apiLoadHistory(forceRefresh); + loadingEl.style.display = 'none'; + if (result.success) { + state.lastHistoryItems = result.history; + renderHistory(state.lastHistoryItems); + } else { + errorEl.textContent = result.error || 'Failed to load history.'; + errorEl.style.display = 'block'; + } + } catch (err) { + loadingEl.style.display = 'none'; + errorEl.textContent = 'Failed to load history.'; + errorEl.style.display = 'block'; + console.error('[History] Load error:', err); + } +} + +export function renderHistory(items) { + const listEl = document.getElementById('history-list'); + const noHistoryEl = document.getElementById('no-history'); + listEl.innerHTML = ''; + const visible = state.ignoreAvailable + ? items.filter(item => !(item.outcome === 'failed' && item.availableForUpgrade)) + : items; + if (!visible.length) { + noHistoryEl.style.display = 'block'; + return; + } + noHistoryEl.style.display = 'none'; + visible.forEach(item => listEl.appendChild(createHistoryCard(item))); +} + +export function createHistoryCard(item) { + const card = document.createElement('div'); + card.className = `history-card ${item.type} ${item.outcome}`; + + if (item.coverArt) { + const coverDiv = document.createElement('div'); + coverDiv.className = 'history-cover'; + const img = document.createElement('img'); + img.src = '/api/dashboard/cover-art?url=' + encodeURIComponent(item.coverArt); + img.alt = item.movieName || item.seriesName || item.title; + img.loading = 'lazy'; + coverDiv.appendChild(img); + card.appendChild(coverDiv); + } + + const info = document.createElement('div'); + info.className = 'history-info'; + + // Header row: type badge + outcome badge + const header = document.createElement('div'); + header.className = 'history-card-header'; + + const typeBadge = document.createElement('span'); + typeBadge.className = `history-type-badge ${item.type}`; + typeBadge.textContent = item.type === 'series' ? 'πŸ“Ί Series' : '🎬 Movie'; + header.appendChild(typeBadge); + + const outcomeBadge = document.createElement('span'); + outcomeBadge.className = `history-outcome-badge ${item.outcome}`; + outcomeBadge.textContent = item.outcome === 'imported' ? 'βœ“ Imported' : 'βœ— Failed'; + header.appendChild(outcomeBadge); + + if (item.availableForUpgrade) { + const upgradeBadge = document.createElement('span'); + upgradeBadge.className = 'history-upgrade-badge'; + upgradeBadge.title = 'A previous version of this item is available. An upgrade download has failed.'; + upgradeBadge.textContent = '⬆ Available'; + header.appendChild(upgradeBadge); + } + + if (item.instanceName) { + const instBadge = document.createElement('span'); + instBadge.className = 'history-instance-badge'; + instBadge.textContent = item.instanceName; + header.appendChild(instBadge); + } + + if (state.showAll && item.tagBadges && item.tagBadges.length > 0) { + const unmatched = item.tagBadges.filter(b => !b.matchedUser); + const matched = item.tagBadges.filter(b => b.matchedUser); + for (const b of unmatched) { + const badge = document.createElement('span'); + badge.className = 'download-user-badge unmatched'; + badge.textContent = b.label; + header.appendChild(badge); + } + for (const b of matched) { + const badge = document.createElement('span'); + badge.className = 'download-user-badge'; + badge.textContent = b.matchedUser; + header.appendChild(badge); + } + } else if (item.matchedUserTag) { + const badge = document.createElement('span'); + badge.className = 'download-user-badge'; + badge.textContent = item.matchedUserTag; + header.appendChild(badge); + } + + info.appendChild(header); + + // Title + const title = document.createElement('h3'); + title.className = 'history-title'; + title.textContent = item.title; + info.appendChild(title); + + // Series/movie name with optional arr link + if (item.seriesName) { + const p = document.createElement('p'); + p.className = 'history-media-name'; + if (state.isAdmin && item.arrLink) { + p.innerHTML = 'Series: ' + escapeHtml(item.seriesName) + ''; + } else { + p.textContent = 'Series: ' + item.seriesName; + } + info.appendChild(p); + const epEl = formatEpisodeInfo(item.episodes); + if (epEl) info.appendChild(epEl); + } + if (item.movieName) { + const p = document.createElement('p'); + p.className = 'history-media-name'; + if (state.isAdmin && item.arrLink) { + p.innerHTML = 'Movie: ' + escapeHtml(item.movieName) + ''; + } else { + p.textContent = 'Movie: ' + item.movieName; + } + info.appendChild(p); + } + + // Detail pills + const details = document.createElement('div'); + details.className = 'history-details'; + + if (item.completedAt) { + details.appendChild(createDetailItem('Completed', formatDate(item.completedAt))); + } + if (item.quality) { + details.appendChild(createDetailItem('Quality', item.quality)); + } + + // Failed imports: show failure message + if (item.outcome === 'failed' && item.failureMessage) { + const failItem = document.createElement('div'); + failItem.className = 'history-failure-message'; + failItem.textContent = item.failureMessage; + details.appendChild(failItem); + } + + info.appendChild(details); + card.appendChild(info); + return card; +} + +function createDetailItem(label, value) { + const item = document.createElement('div'); + item.className = 'detail-item'; + item.dataset.label = label; + + const labelSpan = document.createElement('span'); + labelSpan.className = 'detail-label'; + labelSpan.textContent = label; + + const valueSpan = document.createElement('span'); + valueSpan.className = 'detail-value'; + valueSpan.textContent = value; + + item.appendChild(labelSpan); + item.appendChild(valueSpan); + + return item; +} diff --git a/client/src/ui/statusPanel.js b/client/src/ui/statusPanel.js new file mode 100644 index 0000000..bf39e41 --- /dev/null +++ b/client/src/ui/statusPanel.js @@ -0,0 +1,194 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. + +import { state, STATUS_REFRESH_MS } from '../state.js'; +import { refreshStatusPanel as apiRefreshStatusPanel } from '../api.js'; +import { fetchWebhookStatus } from './webhooks.js'; + +export async function toggleStatusPanel() { + const panel = document.getElementById('status-panel'); + const webhooksSection = document.getElementById('webhooks-section'); + if (panel.style.display !== 'none') { + // Close both panels (webhooks is a sibling, hide it too) + panel.style.display = 'none'; + if (webhooksSection) webhooksSection.style.display = 'none'; + if (state.statusRefreshHandle) { clearInterval(state.statusRefreshHandle); state.statusRefreshHandle = null; } + return; + } + // Open status panel and webhooks section (siblings) + panel.style.display = 'block'; + // Show webhooks section for admin users (collapsed by default) + if (webhooksSection && state.isAdmin) { + webhooksSection.style.display = 'block'; + state.webhookSectionExpanded = false; + document.getElementById('webhooks-content').style.display = 'none'; + document.getElementById('webhooks-toggle').classList.remove('expanded'); + await fetchWebhookStatus(); + } else if (webhooksSection) { + webhooksSection.style.display = 'none'; + } + refreshStatusPanel(); + if (state.statusRefreshHandle) clearInterval(state.statusRefreshHandle); + state.statusRefreshHandle = setInterval(refreshStatusPanel, STATUS_REFRESH_MS); +} + +export function closeStatusPanel() { + document.getElementById('status-panel').style.display = 'none'; + const webhooksSection = document.getElementById('webhooks-section'); + if (webhooksSection) webhooksSection.style.display = 'none'; + if (state.statusRefreshHandle) { clearInterval(state.statusRefreshHandle); state.statusRefreshHandle = null; } +} + +export async function refreshStatusPanel() { + const panel = document.getElementById('status-panel'); + const contentDiv = document.getElementById('status-content'); + console.log('[Status] panel found:', !!panel, 'contentDiv found:', !!contentDiv, 'panel display:', panel?.style?.display); + if (!panel || panel.style.display === 'none') return; + console.log('[Status] Refreshing status panel...'); + try { + const result = await apiRefreshStatusPanel(); + if (result.success) { + console.log('[Status] Got status data, rendering...'); + renderStatusPanel(result.data, panel); + } + } catch (err) { + console.error('[Status] Error fetching status:', err); + // Don't overwrite panel on transient error during auto-refresh + if (contentDiv && (!contentDiv.innerHTML || contentDiv.innerHTML.includes('status-loading'))) { + contentDiv.innerHTML = '

Failed to load status: ' + err.message + '

'; + } + } +} + +export function renderStatusPanel(data, panel) { + console.log('[Status] renderStatusPanel called with data:', data ? 'yes' : 'no', 'keys:', data ? Object.keys(data) : 'none'); + const s = data.server; + const hrs = Math.floor(s.uptimeSeconds / 3600); + const mins = Math.floor((s.uptimeSeconds % 3600) / 60); + const secs = s.uptimeSeconds % 60; + const uptime = `${hrs}h ${mins}m ${secs}s`; + + const totalKB = (data.cache.totalSizeBytes / 1024).toFixed(1); + + let html = ` +
+

Server Status

+ +
+
+
+
Server
+
Uptime${uptime}
+
Node${escapeHtml(s.nodeVersion)}
+
Memory (RSS)${s.memoryUsageMB} MB
+
Heap${s.heapUsedMB} / ${s.heapTotalMB} MB
+
+
+
Data Refresh
`; + + const pollIntervalMs = data.polling.intervalMs; + const clients = data.clients || []; + const sseClients = clients.filter(c => c.type === 'sse'); + + if (data.polling.enabled) { + html += `
Background poll${pollIntervalMs / 1000}s
`; + } else { + html += `
Background pollDisabled
`; + } + + const mode = sseClients.length > 0 + ? `SSE push` + : (data.polling.enabled ? 'Background' : 'On-demand (idle)'); + html += `
Delivery mode${mode}
`; + + html += `
SSE clients${sseClients.length}
`; + for (const c of sseClients) { + const age = Math.round((Date.now() - c.connectedAt) / 1000); + html += `
${escapeHtml(c.user)}connected ${age}s ago
`; + } + + html += `
`; + + // Webhook metrics card (admin only) + if (state.isAdmin && data.webhooks) { + const wh = data.webhooks; + const sonarrEnabled = wh.sonarr?.enabled ? '●' : 'β—‹'; + const radarrEnabled = wh.radarr?.enabled ? '●' : 'β—‹'; + const sonarrEvents = wh.sonarr?.eventsReceived || 0; + const radarrEvents = wh.radarr?.eventsReceived || 0; + const sonarrPolls = wh.sonarr?.pollsSkipped || 0; + const radarrPolls = wh.radarr?.pollsSkipped || 0; + + html += ` +
+
Webhooks
+
Sonarr${sonarrEnabled} ${wh.sonarr?.enabled ? 'Enabled' : 'Disabled'}
+
Radarr${radarrEnabled} ${wh.radarr?.enabled ? 'Enabled' : 'Disabled'}
+
EventsS:${sonarrEvents} R:${radarrEvents}
+
Polls skippedS:${sonarrPolls} R:${radarrPolls}
+
`; + } + + // Poll timings card + const lp = data.polling.lastPoll; + if (lp) { + const pollAge = Math.round((Date.now() - new Date(lp.timestamp).getTime()) / 1000); + html += ` +
+
Last Poll (${lp.totalMs}ms total, ${pollAge}s ago)
+
`; + const maxTaskMs = lp.tasks.reduce((max, t) => Math.max(max, t.ms), 1); + for (const t of lp.tasks) { + const barWidth = Math.max(2, (t.ms / maxTaskMs) * 100); + html += ` +
+ ${escapeHtml(t.label)} +
+ ${t.ms}ms +
`; + } + html += `
`; + } + + // Cache table + html += ` +
+
Cache (${data.cache.entryCount} entries, ${totalKB} KB)
+ + + `; + + for (const e of data.cache.entries) { + const sizeStr = e.sizeBytes > 1024 ? (e.sizeBytes / 1024).toFixed(1) + ' KB' : e.sizeBytes + ' B'; + const ttlStr = e.expired ? 'expired' : (e.ttlRemainingMs / 1000).toFixed(0) + 's'; + const items = e.itemCount !== null ? e.itemCount : 'β€”'; + html += ``; + } + + html += `
KeyItemsSizeTTL
${escapeHtml(e.key)}${items}${sizeStr}${ttlStr}
`; + // Render into status-content div, not the whole panel (preserves webhooks section) + const contentDiv = document.getElementById('status-content'); + const panelCheck = document.getElementById('status-panel'); + console.log('[Status] contentDiv found:', !!contentDiv, 'panel children:', panelCheck?.children?.length, 'HTML length:', html.length); + if (panelCheck) { + console.log('[Status] panel innerHTML preview:', panelCheck.innerHTML.substring(0, 200)); + } + if (contentDiv) { + contentDiv.innerHTML = html; + console.log('[Status] HTML rendered, contentDiv innerHTML length:', contentDiv.innerHTML.length); + } else { + console.error('[Status] contentDiv not found!'); + } + // Wire close button β€” addEventListener avoids CSP inline handler restrictions + const closeBtn = document.getElementById('status-close-btn'); + if (closeBtn) closeBtn.addEventListener('click', closeStatusPanel); + // Set bar widths via JS DOM assignment β€” immune to CSP style-src restrictions + panel.querySelectorAll('.timing-bar[data-w]').forEach(el => { + el.style.width = el.dataset.w + '%'; + }); +} + +function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} diff --git a/client/src/ui/tabs.js b/client/src/ui/tabs.js new file mode 100644 index 0000000..4e064c8 --- /dev/null +++ b/client/src/ui/tabs.js @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. + +import { getActiveTab, saveActiveTab } from '../utils/storage.js'; +import { loadHistory } from './history.js'; + +export function initTabs() { + const downloadsTab = document.getElementById('downloads-tab'); + const historyTab = document.getElementById('history-tab'); + const downloadsSection = document.getElementById('downloads-section'); + const historySection = document.getElementById('history-section'); + + if (!downloadsTab || !historyTab) return; + + // Load saved tab + const savedTab = getActiveTab(); + if (savedTab === 'history') { + activateTab('history'); + } else { + activateTab('downloads'); + } + + downloadsTab.addEventListener('click', () => activateTab('downloads')); + historyTab.addEventListener('click', () => activateTab('history')); +} + +export function activateTab(tab) { + const downloadsTab = document.getElementById('downloads-tab'); + const historyTab = document.getElementById('history-tab'); + const downloadsSection = document.getElementById('downloads-section'); + const historySection = document.getElementById('history-section'); + + if (tab === 'downloads') { + downloadsTab.classList.add('active'); + historyTab.classList.remove('active'); + downloadsSection.style.display = 'block'; + historySection.style.display = 'none'; + saveActiveTab('downloads'); + } else if (tab === 'history') { + historyTab.classList.add('active'); + downloadsTab.classList.remove('active'); + historySection.style.display = 'block'; + downloadsSection.style.display = 'none'; + saveActiveTab('history'); + loadHistory(); + } +} + +export function goHome() { + activateTab('downloads'); +} diff --git a/client/src/ui/theme.js b/client/src/ui/theme.js new file mode 100644 index 0000000..c880a77 --- /dev/null +++ b/client/src/ui/theme.js @@ -0,0 +1,27 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. + +import { getTheme, saveTheme } from '../utils/storage.js'; + +// Apply saved theme immediately on load +(function applyTheme() { + const theme = getTheme(); + if (theme) { + document.documentElement.setAttribute('data-theme', theme); + } +})(); + +export function initThemeSwitcher() { + const themeToggle = document.getElementById('theme-toggle'); + if (!themeToggle) return; + + themeToggle.addEventListener('click', () => { + const currentTheme = getTheme(); + const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; + setTheme(newTheme); + }); +} + +export function setTheme(theme) { + document.documentElement.setAttribute('data-theme', theme); + saveTheme(theme); +} diff --git a/client/src/ui/webhooks.js b/client/src/ui/webhooks.js new file mode 100644 index 0000000..ed35db5 --- /dev/null +++ b/client/src/ui/webhooks.js @@ -0,0 +1,192 @@ +// 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 { formatTimeAgo } from '../utils/format.js'; + +export function initWebhooks() { + const webhooksSection = document.getElementById('webhooks-section'); + if (!webhooksSection) return; + + // Note: visibility is controlled by showDashboard() based on isAdmin + + 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('test-sonarr-webhook').addEventListener('click', testSonarrWebhook); + document.getElementById('test-radarr-webhook').addEventListener('click', testRadarrWebhook); +} + +export function toggleWebhookSection() { + state.webhookSectionExpanded = !state.webhookSectionExpanded; + const content = document.getElementById('webhooks-content'); + const toggle = document.getElementById('webhooks-toggle'); + + content.style.display = state.webhookSectionExpanded ? '' : 'none'; + toggle.classList.toggle('expanded', state.webhookSectionExpanded); + + if (state.webhookSectionExpanded) { + fetchWebhookStatus(); + } +} + +export async function fetchWebhookStatus() { + const loadingEl = document.getElementById('webhook-loading'); + loadingEl.style.display = ''; + + try { + const result = await apiFetchWebhookStatus(); + if (result.success) { + renderWebhookStatus(); + } + } catch (err) { + console.error('Failed to fetch webhook status:', err); + } finally { + loadingEl.style.display = 'none'; + } +} + +export function renderWebhookStatus() { + // Sonarr + const sonarrStatus = document.getElementById('sonarr-status'); + const sonarrEnableBtn = document.getElementById('enable-sonarr-webhook'); + const sonarrTestBtn = document.getElementById('test-sonarr-webhook'); + 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'); + sonarrEnableBtn.style.display = sonarrWebhook.enabled ? 'none' : ''; + sonarrTestBtn.style.display = sonarrWebhook.enabled ? '' : 'none'; + sonarrTriggers.style.display = sonarrWebhook.enabled ? '' : 'none'; + + 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 (sonarrWebhook.stats) { + sonarrStats.style.display = ''; + 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); + } else { + sonarrStats.style.display = 'none'; + } + + // Radarr + const radarrStatus = document.getElementById('radarr-status'); + const radarrEnableBtn = document.getElementById('enable-radarr-webhook'); + const radarrTestBtn = document.getElementById('test-radarr-webhook'); + 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'); + radarrEnableBtn.style.display = radarrWebhook.enabled ? 'none' : ''; + radarrTestBtn.style.display = radarrWebhook.enabled ? '' : 'none'; + radarrTriggers.style.display = radarrWebhook.enabled ? '' : 'none'; + + 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 (radarrWebhook.stats) { + radarrStats.style.display = ''; + 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); + } else { + radarrStats.style.display = 'none'; + } +} + +export async function enableSonarrWebhook() { + setWebhookLoading(true); + try { + const result = await apiEnableSonarrWebhook(); + if (!result.success) { + console.error('Failed to enable Sonarr webhook:', result.error); + alert('Failed to enable Sonarr webhook. Check console for details.'); + } + } catch (err) { + console.error('Failed to enable Sonarr webhook:', err); + alert('Failed to enable Sonarr webhook. Check console for details.'); + } finally { + setWebhookLoading(false); + } +} + +export async function enableRadarrWebhook() { + setWebhookLoading(true); + try { + const result = await apiEnableRadarrWebhook(); + if (!result.success) { + console.error('Failed to enable Radarr webhook:', result.error); + alert('Failed to enable Radarr webhook. Check console for details.'); + } + } catch (err) { + console.error('Failed to enable Radarr webhook:', err); + alert('Failed to enable Radarr webhook. Check console for details.'); + } finally { + setWebhookLoading(false); + } +} + +export async function testSonarrWebhook() { + setWebhookLoading(true); + try { + const result = await apiTestSonarrWebhook(); + if (result.success) { + alert('Sonarr webhook test sent successfully!'); + } else { + console.error('Failed to test Sonarr webhook:', result.error); + alert('Failed to test Sonarr webhook. Check console for details.'); + } + } catch (err) { + console.error('Failed to test Sonarr webhook:', err); + alert('Failed to test Sonarr webhook. Check console for details.'); + } finally { + setWebhookLoading(false); + } +} + +export async function testRadarrWebhook() { + setWebhookLoading(true); + try { + const result = await apiTestRadarrWebhook(); + if (result.success) { + alert('Radarr webhook test sent successfully!'); + } else { + console.error('Failed to test Radarr webhook:', result.error); + alert('Failed to test Radarr webhook. Check console for details.'); + } + } catch (err) { + console.error('Failed to test Radarr webhook:', err); + alert('Failed to test Radarr 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('test-sonarr-webhook').disabled = loading; + document.getElementById('test-radarr-webhook').disabled = loading; + document.getElementById('webhook-loading').style.display = loading ? '' : 'none'; +} diff --git a/client/src/utils/format.js b/client/src/utils/format.js new file mode 100644 index 0000000..2c8ae63 --- /dev/null +++ b/client/src/utils/format.js @@ -0,0 +1,74 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. + +export function formatSize(size) { + if (!size) return 'N/A'; + // If already a formatted string (e.g., "21.5 GB"), return as-is + if (typeof size === 'string') { + return size; + } + // If it's a number (bytes), format it + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(size) / Math.log(1024)); + return Math.round(size / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; +} + +export 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]}`; +} + +export function formatDate(dateString) { + if (!dateString) return 'N/A'; + return new Date(dateString).toLocaleString(); +} + +export function formatTimeAgo(timestamp) { + if (!timestamp) return 'Never'; + const seconds = Math.floor((Date.now() - timestamp) / 1000); + if (seconds < 60) return seconds + 's ago'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return minutes + 'm ago'; + const hours = Math.floor(minutes / 60); + if (hours < 24) return hours + 'h ago'; + return Math.floor(hours / 24) + 'd ago'; +} + +export function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +// Build an episode-info element for series downloads/history. +// Single episode: "S01E05 β€” Episode Title" +// Multiple episodes: "Multiple episodes" with tooltip listing them all. +// Returns null if no episode data. +export function formatEpisodeInfo(episodes) { + if (!episodes || episodes.length === 0) return null; + const el = document.createElement('p'); + el.className = 'episode-info'; + if (episodes.length === 1) { + const ep = episodes[0]; + const code = 'S' + String(ep.season).padStart(2, '0') + 'E' + String(ep.episode).padStart(2, '0'); + el.textContent = ep.title ? code + ' \u2014 ' + ep.title : code; + } else { + el.textContent = 'Multiple episodes'; + el.classList.add('multi-episode'); + const lines = episodes.map(ep => { + const code = 'S' + String(ep.season).padStart(2, '0') + 'E' + String(ep.episode).padStart(2, '0'); + return ep.title ? code + ' \u2014 ' + ep.title : code; + }); + el.setAttribute('data-tooltip', lines.join('\n')); + } + return el; +} diff --git a/client/src/utils/storage.js b/client/src/utils/storage.js new file mode 100644 index 0000000..73a9a64 --- /dev/null +++ b/client/src/utils/storage.js @@ -0,0 +1,76 @@ +// Copyright (c) 2026 Gordon Bolton. MIT License. + +import { state } from '../state.js'; + +// Migration from old single-select to new multi-select format +(function migrateDownloadClientFilter() { + const oldSelection = localStorage.getItem('sofarr-download-client'); + if (oldSelection && oldSelection !== 'all') { + try { + state.selectedDownloadClients = [oldSelection]; + localStorage.setItem('sofarr-download-clients', JSON.stringify(state.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'); + state.selectedDownloadClients = newSelection ? JSON.parse(newSelection) : []; + } catch (e) { + console.error('[Migration] Failed to load download client filter:', e); + state.selectedDownloadClients = []; + } + } +})(); + +// Load history days from localStorage +(function loadHistorySettings() { + try { + const savedDays = localStorage.getItem('sofarr-history-days'); + if (savedDays) { + state.historyDays = parseInt(savedDays, 10) || 7; + } + } catch (e) { + console.error('[Storage] Failed to load history days:', e); + } +})(); + +// Load ignore available setting from localStorage +(function loadIgnoreAvailable() { + try { + const saved = localStorage.getItem('sofarr-ignore-available'); + state.ignoreAvailable = saved === 'true'; + } catch (e) { + console.error('[Storage] Failed to load ignore available:', e); + } +})(); + +// Export helper functions for localStorage operations +export function saveHistoryDays(days) { + localStorage.setItem('sofarr-history-days', days); +} + +export function saveIgnoreAvailable(value) { + localStorage.setItem('sofarr-ignore-available', value); +} + +export function saveDownloadClients(clients) { + localStorage.setItem('sofarr-download-clients', JSON.stringify(clients)); +} + +export function getTheme() { + return localStorage.getItem('sofarr-theme') || 'light'; +} + +export function saveTheme(theme) { + localStorage.setItem('sofarr-theme', theme); +} + +export function getActiveTab() { + return localStorage.getItem('sofarr-active-tab') || 'downloads'; +} + +export function saveActiveTab(tab) { + localStorage.setItem('sofarr-active-tab', tab); +} diff --git a/client/vite.config.js b/client/vite.config.js index bf237b5..d42a573 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -1,9 +1,21 @@ // Copyright (c) 2026 Gordon Bolton. MIT License. import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' export default defineConfig({ - plugins: [react()], + build: { + outDir: '../public', + emptyOutDir: false, + rollupOptions: { + input: { + main: './src/main.js' + }, + output: { + entryFileNames: 'app.js', + chunkFileNames: '[name].js', + assetFileNames: '[name][extname]' + } + } + }, server: { port: 5173, proxy: { diff --git a/public/app.js b/public/app.js index d3ecdd2..8929d03 100644 --- a/public/app.js +++ b/public/app.js @@ -1,1050 +1,11 @@ -// 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; -const HISTORY_REFRESH_MS = 5 * 60 * 1000; // auto-refresh history every 5 min -let ignoreAvailable = localStorage.getItem('sofarr-ignore-available') === 'true'; -let lastHistoryItems = []; // raw items from last fetch, for re-filtering without a network round-trip - -// SSE stream state -let sseSource = null; -let sseReconnectTimer = null; -const SSE_RECONNECT_MS = 3000; // browser already retries, but we add explicit backoff too - -// Apply saved theme immediately (before DOMContentLoaded to avoid flash) -(function() { - const saved = localStorage.getItem('sofarr-theme') || 'light'; - document.documentElement.setAttribute('data-theme', saved); -})(); - -// Check authentication on load -document.addEventListener('DOMContentLoaded', () => { - checkAuthentication(); - initThemeSwitcher(); - initTabs(); - initHistoryControls(); - initDownloadClientFilter(); - initWebhooks(); - loadAppVersion(); - - document.getElementById('login-form').addEventListener('submit', handleLogin); - document.getElementById('logout-btn').addEventListener('click', handleLogout); - document.getElementById('show-all-toggle').addEventListener('change', handleShowAllToggle); - document.getElementById('status-btn').addEventListener('click', toggleStatusPanel); - document.getElementById('title-home-link').addEventListener('click', e => { e.preventDefault(); goHome(); }); -}); - -function loadAppVersion() { - fetch('/health') - .then(r => r.json()) - .then(data => { - if (data.version) { - document.getElementById('app-version').textContent = `sofarr v${data.version}`; - } - }) - .catch(() => {}); -} - -function initThemeSwitcher() { - const saved = localStorage.getItem('sofarr-theme') || 'light'; - document.querySelectorAll('.theme-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.theme === saved); - btn.addEventListener('click', () => setTheme(btn.dataset.theme)); - }); -} - -function setTheme(theme) { - document.documentElement.setAttribute('data-theme', theme); - localStorage.setItem('sofarr-theme', theme); - document.querySelectorAll('.theme-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.theme === theme); - }); -} - -function goHome() { - closeStatusPanel(); - // Reset showAll if active - if (showAll) { - showAll = false; - const toggle = document.getElementById('show-all-toggle'); - if (toggle) toggle.checked = false; - startSSE(); - } - activateTab('downloads', true); -} - -function initTabs() { - const savedTab = localStorage.getItem('sofarr-active-tab') || 'downloads'; - activateTab(savedTab, false); - - document.querySelectorAll('.tab-btn').forEach(btn => { - btn.addEventListener('click', () => { - const tab = btn.dataset.tab; - activateTab(tab, true); - }); - }); -} - -function activateTab(tabName, save) { - document.querySelectorAll('.tab-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.tab === tabName); - }); - document.querySelectorAll('.tab-panel').forEach(panel => { - panel.style.display = panel.id === `tab-${tabName}` ? '' : 'none'; - }); - if (save) localStorage.setItem('sofarr-active-tab', tabName); - // Load history the first time the history tab is shown - if (tabName === 'history') loadHistory(); -} - -// --- SSE connection management --- - -function startSSE() { - stopSSE(); - const params = showAll ? '?showAll=true' : ''; - const source = new EventSource('/api/dashboard/stream' + params); - sseSource = source; - - let firstMessage = true; - source.onmessage = (event) => { - try { - const data = JSON.parse(event.data); - 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(); - if (firstMessage) { firstMessage = false; hideLoading(); } - } catch (err) { - console.error('[SSE] Failed to parse message:', err); - } - }; - - source.onerror = () => { - // EventSource retries automatically; we just log and show a reconnecting indicator - console.warn('[SSE] Connection lost, browser will retry...'); - }; - - console.log('[SSE] Stream connected'); -} - -function stopSSE() { - if (sseReconnectTimer) { clearTimeout(sseReconnectTimer); sseReconnectTimer = null; } - if (sseSource) { - sseSource.close(); - sseSource = null; - console.log('[SSE] Stream closed'); - } -} - -function handleShowAllToggle(e) { - showAll = e.target.checked; - // Re-open stream with updated showAll param - startSSE(); - // Reload history with updated showAll param - loadHistory(); -} - -function fadeOutLogin() { - return new Promise(resolve => { - const login = document.getElementById('login-container'); - login.classList.add('fade-out'); - login.addEventListener('transitionend', () => { - login.style.display = 'none'; - login.classList.remove('fade-out'); - resolve(); - }, { once: true }); - }); -} - -function showSplash() { - const splash = document.getElementById('splash-screen'); - splash.style.display = 'flex'; - splash.style.opacity = '1'; - splash.classList.remove('fade-out'); -} - -function dismissSplash(startTime) { - return new Promise(resolve => { - const elapsed = Date.now() - (startTime || 0); - const remaining = Math.max(0, SPLASH_MIN_MS - elapsed); - setTimeout(() => { - const splash = document.getElementById('splash-screen'); - splash.classList.add('fade-out'); - // Fallback: resolve after transition duration + buffer in case - // transitionend never fires (e.g. display was toggled in same frame) - const TRANSITION_MS = 400; - const fallback = setTimeout(() => { - splash.style.display = 'none'; - resolve(); - }, TRANSITION_MS + 100); - splash.addEventListener('transitionend', () => { - clearTimeout(fallback); - splash.style.display = 'none'; - resolve(); - }, { once: true }); - }, remaining); - }); -} - -async function checkAuthentication() { - const splashStart = Date.now(); - try { - // Fetch both auth state and a fresh CSRF token in parallel - const [meRes, csrfRes] = await Promise.all([ - fetch('/api/auth/me'), - fetch('/api/auth/csrf') - ]); - const data = await meRes.json(); - const csrfData = await csrfRes.json(); - if (csrfData.csrfToken) csrfToken = csrfData.csrfToken; - - if (data.authenticated) { - currentUser = data.user; - isAdmin = !!data.user.isAdmin; - showDashboard(); - showLoading(); - startSSE(); - await dismissSplash(splashStart); - } else { - await dismissSplash(splashStart); - showLogin(); - } - } catch (err) { - console.error('Authentication check failed:', err); - await dismissSplash(splashStart); - showLogin(); - } -} - -async function handleLogin(e) { - e.preventDefault(); - - const username = document.getElementById('username').value; - const password = document.getElementById('password').value; - const rememberMe = document.getElementById('remember-me').checked; - - try { - const response = await fetch('/api/auth/login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ username, password, rememberMe }) - }); - - const data = await response.json(); - - if (data.success) { - currentUser = data.user; - isAdmin = !!data.user.isAdmin; - // Store CSRF token returned by login for use in subsequent requests - if (data.csrfToken) csrfToken = data.csrfToken; - // Fade out login, then show splash while opening SSE stream. - // requestAnimationFrame ensures the browser paints the splash at - // opacity:1 before dismissSplash adds fade-out, so the CSS - // transition fires and transitionend is guaranteed. - await fadeOutLogin(); - showSplash(); - await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); - showDashboard(); - showLoading(); - const splashStart = Date.now(); - startSSE(); - await dismissSplash(splashStart); - } else { - showLoginError(data.error || 'Login failed'); - } - } catch (err) { - showLoginError('Login failed. Please try again.'); - console.error(err); - } -} - -async function handleLogout() { - try { - stopSSE(); - stopHistoryRefresh(); - if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; } - await fetch('/api/auth/logout', { - method: 'POST', - headers: csrfToken ? { 'X-CSRF-Token': csrfToken } : {} - }); - currentUser = null; - csrfToken = null; - downloads = []; - clearHistory(); - showLogin(); - } catch (err) { - console.error('Logout failed:', err); - } -} - -function showLogin() { - document.getElementById('login-container').style.display = 'flex'; - document.getElementById('dashboard-container').style.display = 'none'; - hideLoginError(); -} - -function showDashboard() { - document.getElementById('login-container').style.display = 'none'; - document.getElementById('dashboard-container').style.display = 'block'; - document.getElementById('currentUser').textContent = currentUser.name || '-'; - // Always start with status panel hidden (guards against stale display value on re-login) - const sp = document.getElementById('status-panel'); - sp.style.display = 'none'; - // Also hide webhooks-section to keep them in sync (both show/hide together) - const webhooksSection = document.getElementById('webhooks-section'); - if (webhooksSection) webhooksSection.style.display = 'none'; - document.getElementById('admin-controls').style.display = isAdmin ? 'flex' : 'none'; - // Note: webhooks-section visibility is controlled by toggleStatusPanel() - // Initialise days input from saved value - const daysInput = document.getElementById('history-days'); - if (daysInput) daysInput.value = historyDays; - startHistoryRefresh(); -} - -function showLoginError(message) { - const errorDiv = document.getElementById('login-error'); - errorDiv.textContent = message; - errorDiv.style.display = 'block'; -} - -function hideLoginError() { - const errorDiv = document.getElementById('login-error'); - errorDiv.style.display = 'none'; -} - -// Build an episode-info element for series downloads/history. -// Single episode: "S01E05 β€” Episode Title" -// Multiple episodes: "Multiple episodes" with tooltip listing them all. -// Returns null if no episode data. -function formatEpisodeInfo(episodes) { - if (!episodes || episodes.length === 0) return null; - const el = document.createElement('p'); - el.className = 'episode-info'; - if (episodes.length === 1) { - const ep = episodes[0]; - const code = 'S' + String(ep.season).padStart(2, '0') + 'E' + String(ep.episode).padStart(2, '0'); - el.textContent = ep.title ? code + ' \u2014 ' + ep.title : code; - } else { - el.textContent = 'Multiple episodes'; - el.classList.add('multi-episode'); - const lines = episodes.map(ep => { - const code = 'S' + String(ep.season).padStart(2, '0') + 'E' + String(ep.episode).padStart(2, '0'); - return ep.title ? code + ' \u2014 ' + ep.title : code; - }); - el.setAttribute('data-tooltip', lines.join('\n')); - } - return el; -} - -// fetchUserDownloads is kept for the showAll toggle re-connection case -// but the primary data path is now via SSE (startSSE / EventSource). - -function renderDownloads() { - const downloadsList = document.getElementById('downloads-list'); - const noDownloads = document.getElementById('no-downloads'); - - // 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(); - - filteredDownloads.forEach(download => { - const id = download.title; - processedIds.add(id); - - const existingCard = existingCards.get(id); - if (existingCard) { - // Update existing card - updateDownloadCard(existingCard, download); - } else { - // Create new card - const card = createDownloadCard(download); - downloadsList.appendChild(card); - } - }); - - // Remove cards for downloads that no longer exist - existingCards.forEach((card, id) => { - if (!processedIds.has(id)) { - card.remove(); - } - }); -} - -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) { - statusEl.textContent = download.status; - statusEl.className = `download-status ${download.status}`; - } - - // Update progress bar and missing pieces - const progressContainer = card.querySelector('.progress-container'); - if (progressContainer && download.progress !== undefined) { - const progressBar = progressContainer.querySelector('.progress-bar'); - const progressText = progressContainer.querySelector('.progress-text'); - const missingText = progressContainer.querySelector('.missing-text'); - - if (progressBar) { - const downloaded = progressBar.querySelector('.downloaded'); - if (downloaded) { - downloaded.style.width = download.progress + '%'; - } - } - - if (progressText) { - progressText.textContent = download.progress + '%'; - } - - if (missingText) { - const totalMb = parseFloat(download.mb) || parseFloat(download.size); - const missingMb = parseFloat(download.mbmissing) || 0; - if (missingMb > 0 && totalMb > 0) { - missingText.textContent = `(missing ${missingMb.toFixed(1)} of ${totalMb.toFixed(1)} MB)`; - } else { - missingText.textContent = ''; - } - } - } - - // Update speed - const speedEl = card.querySelector('.detail-item[data-label="Speed"] .detail-value'); - if (speedEl && download.speed !== undefined) { - speedEl.textContent = download.speed; - } - - // Update ETA - const etaEl = card.querySelector('.detail-item[data-label="ETA"] .detail-value'); - if (etaEl && download.eta !== undefined) { - etaEl.textContent = download.eta; - } - - // Update qBittorrent-specific fields - if (download.qbittorrent) { - const seedsEl = card.querySelector('.detail-item[data-label="Seeds"] .detail-value'); - if (seedsEl && download.seeds !== undefined) { - seedsEl.textContent = download.seeds; - } - - const peersEl = card.querySelector('.detail-item[data-label="Peers"] .detail-value'); - if (peersEl && download.peers !== undefined) { - peersEl.textContent = download.peers; - } - - const availabilityItem = card.querySelector('.detail-item[data-label="Availability"]'); - if (availabilityItem && download.availability !== undefined) { - availabilityItem.querySelector('.detail-value').textContent = `${download.availability}%`; - availabilityItem.classList.toggle('availability-warning', parseFloat(download.availability) < 100); - } - } -} - -async function handleBlocklistSearch(btn, download) { - if (!confirm(`Blocklist "${download.title}" and trigger a new search?\n\nThis will:\nβ€’ Remove the download from the download client\nβ€’ Add this release to the blocklist\nβ€’ Trigger an automatic search for a new release`)) return; - - btn.disabled = true; - btn.textContent = '⏳ Working…'; - - try { - const res = await fetch('/api/dashboard/blocklist-search', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': csrfToken - }, - body: JSON.stringify({ - arrQueueId: download.arrQueueId, - arrType: download.arrType, - arrInstanceUrl: download.arrInstanceUrl, - arrInstanceKey: download.arrInstanceKey, - arrContentId: download.arrContentId, - arrContentType: download.arrContentType - }) - }); - - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.error || `HTTP ${res.status}`); - } - - btn.textContent = 'βœ“ Done β€” searching…'; - btn.className = 'blocklist-search-btn success'; - } catch (err) { - console.error('[Blocklist] Error:', err); - btn.disabled = false; - btn.textContent = 'β›” Blocklist & Search'; - btn.className = 'blocklist-search-btn error'; - btn.title = `Failed: ${err.message}`; - setTimeout(() => { - btn.className = 'blocklist-search-btn'; - btn.title = 'Remove this release from the download client, add it to the blocklist, and trigger a new automatic search'; - }, 4000); - } -} - -function createDownloadCard(download) { - const card = document.createElement('div'); - card.className = `download-card ${download.type}`; - card.dataset.id = download.title; - - // Cover art - if (download.coverArt) { - const coverDiv = document.createElement('div'); - coverDiv.className = 'download-cover'; - const coverImg = document.createElement('img'); - // Proxy cover art through the server so the CSP img-src 'self' rule - // is satisfied (external poster URLs would be blocked otherwise). - coverImg.src = download.coverArt - ? '/api/dashboard/cover-art?url=' + encodeURIComponent(download.coverArt) - : ''; - coverImg.alt = download.movieName || download.seriesName || download.title; - coverImg.loading = 'lazy'; - coverDiv.appendChild(coverImg); - card.appendChild(coverDiv); - } - - // Info wrapper - const infoDiv = document.createElement('div'); - infoDiv.className = 'download-info'; - - const header = document.createElement('div'); - header.className = 'download-header'; - - const type = document.createElement('span'); - type.className = `download-type ${download.type}`; - if (download.type === 'series') { - type.textContent = 'πŸ“Ί Series'; - } else if (download.type === 'movie') { - type.textContent = '🎬 Movie'; - } else if (download.type === 'torrent') { - const instName = download.instanceName ? ` (${download.instanceName})` : ''; - type.textContent = `πŸ“₯ Torrent${instName}`; - } else { - type.textContent = download.type; - } - - const status = document.createElement('span'); - status.className = `download-status ${download.status}`; - status.textContent = download.status; - - header.appendChild(type); - header.appendChild(status); - - if (download.importIssues && download.importIssues.length > 0) { - const issueBadge = document.createElement('span'); - issueBadge.className = 'import-issue-badge'; - issueBadge.textContent = 'Import Pending'; - issueBadge.setAttribute('data-tooltip', download.importIssues.join('\n')); - header.appendChild(issueBadge); - } - - if ((isAdmin || download.canBlocklist) && download.arrQueueId) { - const blBtn = document.createElement('button'); - blBtn.className = 'blocklist-search-btn'; - blBtn.textContent = 'β›” Blocklist & Search'; - blBtn.title = 'Remove this release from the download client, add it to the blocklist, and trigger a new automatic search'; - blBtn.addEventListener('click', () => 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'; - title.textContent = download.title; - - infoDiv.appendChild(header); - infoDiv.appendChild(title); - - if (download.seriesName) { - const series = document.createElement('p'); - series.className = 'download-series'; - if (isAdmin && download.arrLink) { - series.innerHTML = 'Series: ' + escapeHtml(download.seriesName) + ''; - } else { - series.textContent = `Series: ${download.seriesName}`; - } - infoDiv.appendChild(series); - const epEl = formatEpisodeInfo(download.episodes); - if (epEl) infoDiv.appendChild(epEl); - } - - if (download.movieName) { - const movie = document.createElement('p'); - movie.className = 'download-movie'; - if (isAdmin && download.arrLink) { - movie.innerHTML = 'Movie: ' + escapeHtml(download.movieName) + ''; - } else { - movie.textContent = `Movie: ${download.movieName}`; - } - infoDiv.appendChild(movie); - } - - 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; - header.appendChild(badge); - } - for (const b of matched) { - const badge = document.createElement('span'); - badge.className = 'download-user-badge'; - badge.textContent = b.matchedUser; - header.appendChild(badge); - } - } else if (download.matchedUserTag) { - // Normal (non-showAll) view: show only the current user's matched tag - const matchedBadge = document.createElement('span'); - matchedBadge.className = 'download-user-badge'; - matchedBadge.textContent = download.matchedUserTag; - header.appendChild(matchedBadge); - } - - // Add client logo - if (download.client) { - const clientLogoWrapper = document.createElement('span'); - clientLogoWrapper.className = 'download-client-logo-wrapper download-card-logo-wrapper'; - - const clientLogo = document.createElement('img'); - clientLogo.className = 'download-client-logo'; - clientLogo.src = `/images/clients/${download.client}.svg`; - clientLogo.alt = `${download.instanceName || download.client} icon`; - clientLogo.title = download.instanceName || download.client; - clientLogo.onerror = () => { - // Fallback to text if image fails to load - clientLogoWrapper.textContent = download.client.charAt(0).toUpperCase(); - clientLogoWrapper.classList.add('fallback'); - }; - - clientLogoWrapper.appendChild(clientLogo); - header.appendChild(clientLogoWrapper); - } - - const details = document.createElement('div'); - details.className = 'download-details'; - - const size = createDetailItem('Size', formatSize(download.size)); - details.appendChild(size); - - if (download.progress !== undefined) { - const progressItem = document.createElement('div'); - progressItem.className = 'detail-item progress-item'; - progressItem.dataset.label = 'Progress'; - - const labelSpan = document.createElement('span'); - labelSpan.className = 'detail-label'; - labelSpan.textContent = 'Progress'; - - const valueDiv = document.createElement('div'); - valueDiv.className = 'progress-container'; - - // Progress bar with segments - const totalMb = parseFloat(download.mb) || parseFloat(download.size); - const missingMb = parseFloat(download.mbmissing) || 0; - const downloadedMb = totalMb - missingMb; - const progressPercent = parseFloat(download.progress) || 0; - const missingPercent = totalMb > 0 ? (missingMb / totalMb) * 100 : 0; - - const progressBar = document.createElement('div'); - progressBar.className = 'progress-bar'; - - // Downloaded portion (green) - if (progressPercent > 0) { - const downloaded = document.createElement('div'); - downloaded.className = 'progress-segment downloaded'; - downloaded.style.width = progressPercent + '%'; - progressBar.appendChild(downloaded); - } - - valueDiv.appendChild(progressBar); - - // Text showing percentage - const progressText = document.createElement('span'); - progressText.className = 'progress-text'; - progressText.textContent = download.progress + '%'; - valueDiv.appendChild(progressText); - - // Missing pieces text (only for torrent clients like qBittorrent) - if (download.client && (download.client === 'qbittorrent' || download.client === 'rtorrent') && missingMb > 0 && totalMb > 0) { - const missingText = document.createElement('span'); - missingText.className = 'missing-text'; - missingText.textContent = `(missing ${missingMb.toFixed(1)} of ${totalMb.toFixed(1)} MB)`; - valueDiv.appendChild(missingText); - } - - progressItem.appendChild(labelSpan); - progressItem.appendChild(valueDiv); - details.appendChild(progressItem); - } - - if (download.speed && download.speed > 0) { - const speed = createDetailItem('Speed', formatSpeed(download.speed)); - details.appendChild(speed); - } - - if (download.eta) { - const eta = createDetailItem('ETA', download.eta); - details.appendChild(eta); - } - - // qBittorrent-specific fields - if (download.qbittorrent) { - if (download.seeds !== undefined) { - const seeds = createDetailItem('Seeds', download.seeds); - details.appendChild(seeds); - } - - if (download.peers !== undefined) { - const peers = createDetailItem('Peers', download.peers); - details.appendChild(peers); - } - - if (download.availability !== undefined) { - const availability = createDetailItem('Availability', `${download.availability}%`); - if (parseFloat(download.availability) < 100) availability.classList.add('availability-warning'); - details.appendChild(availability); - } - } - - if (download.completedAt) { - const completed = createDetailItem('Completed', formatDate(download.completedAt)); - details.appendChild(completed); - } - - if (isAdmin && (download.downloadPath || download.targetPath)) { - const pathsDiv = document.createElement('div'); - pathsDiv.className = 'download-paths'; - if (download.downloadPath) { - const dlPath = document.createElement('div'); - dlPath.className = 'path-item'; - dlPath.innerHTML = 'Download: ' + escapeHtml(download.downloadPath) + ''; - pathsDiv.appendChild(dlPath); - } - if (download.targetPath) { - const tgtPath = document.createElement('div'); - tgtPath.className = 'path-item'; - tgtPath.innerHTML = 'Target: ' + escapeHtml(download.targetPath) + ''; - pathsDiv.appendChild(tgtPath); - } - details.appendChild(pathsDiv); - } - - infoDiv.appendChild(details); - card.appendChild(infoDiv); - - return card; -} - -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'; - item.dataset.label = label; - - const labelSpan = document.createElement('span'); - labelSpan.className = 'detail-label'; - labelSpan.textContent = label; - - const valueSpan = document.createElement('span'); - valueSpan.className = 'detail-value'; - valueSpan.textContent = value; - - item.appendChild(labelSpan); - item.appendChild(valueSpan); - - return item; -} - -function escapeHtml(str) { - const div = document.createElement('div'); - div.textContent = str; - return div.innerHTML; -} - -let statusRefreshHandle = null; -const STATUS_REFRESH_MS = 5000; - -async function toggleStatusPanel() { - const panel = document.getElementById('status-panel'); - const webhooksSection = document.getElementById('webhooks-section'); - if (panel.style.display !== 'none') { - // Close both panels (webhooks is a sibling, hide it too) - panel.style.display = 'none'; - if (webhooksSection) webhooksSection.style.display = 'none'; - if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; } - return; - } - // Open status panel and webhooks section (siblings) - panel.style.display = 'block'; - // Show webhooks section for admin users (collapsed by default) - if (webhooksSection && isAdmin) { - webhooksSection.style.display = 'block'; - webhookSectionExpanded = false; - document.getElementById('webhooks-content').style.display = 'none'; - document.getElementById('webhooks-toggle').classList.remove('expanded'); - await fetchWebhookStatus(); - } else if (webhooksSection) { - webhooksSection.style.display = 'none'; - } - await refreshStatusPanel(); - if (statusRefreshHandle) clearInterval(statusRefreshHandle); - statusRefreshHandle = setInterval(refreshStatusPanel, STATUS_REFRESH_MS); -} - -function closeStatusPanel() { - document.getElementById('status-panel').style.display = 'none'; - const webhooksSection = document.getElementById('webhooks-section'); - if (webhooksSection) webhooksSection.style.display = 'none'; - if (statusRefreshHandle) { clearInterval(statusRefreshHandle); statusRefreshHandle = null; } -} - -async function refreshStatusPanel() { - const panel = document.getElementById('status-panel'); - const contentDiv = document.getElementById('status-content'); - console.log('[Status] panel found:', !!panel, 'contentDiv found:', !!contentDiv, 'panel display:', panel?.style?.display); - if (!panel || panel.style.display === 'none') return; - console.log('[Status] Refreshing status panel...'); - try { - const res = await fetch('/api/dashboard/status'); - if (!res.ok) throw new Error('Failed to fetch status: ' + res.status); - const data = await res.json(); - console.log('[Status] Got status data, rendering...'); - renderStatusPanel(data, panel); - } catch (err) { - console.error('[Status] Error fetching status:', err); - // Don't overwrite panel on transient error during auto-refresh - if (contentDiv && (!contentDiv.innerHTML || contentDiv.innerHTML.includes('status-loading'))) { - contentDiv.innerHTML = '

Failed to load status: ' + err.message + '

'; - } - } -} - -function renderStatusPanel(data, panel) { - console.log('[Status] renderStatusPanel called with data:', data ? 'yes' : 'no', 'keys:', data ? Object.keys(data) : 'none'); - const s = data.server; - const hrs = Math.floor(s.uptimeSeconds / 3600); - const mins = Math.floor((s.uptimeSeconds % 3600) / 60); - const secs = s.uptimeSeconds % 60; - const uptime = `${hrs}h ${mins}m ${secs}s`; - - const totalKB = (data.cache.totalSizeBytes / 1024).toFixed(1); - - let html = ` +const r={currentUser:null,downloads:[],downloadClients:[],selectedDownloadClients:[],isAdmin:!1,showAll:!1,csrfToken:null,historyDays:7,historyRefreshHandle:null,ignoreAvailable:!1,lastHistoryItems:[],sseSource:null,sseReconnectTimer:null,statusRefreshHandle:null,webhookSectionExpanded:!1,webhookLoading:!1,sonarrWebhook:{enabled:!1,triggers:{onGrab:!1,onDownload:!1,onImport:!1,onUpgrade:!1},stats:null},radarrWebhook:{enabled:!1,triggers:{onGrab:!1,onDownload:!1,onImport:!1,onUpgrade:!1},stats:null},webhookMetrics:null},re=1200,le=5*60*1e3,ie=5e3;async function ce(){try{const[e,t]=await Promise.all([fetch("/api/auth/me"),fetch("/api/auth/csrf")]),n=await e.json(),s=await t.json();return s.csrfToken&&(r.csrfToken=s.csrfToken),n.authenticated?(r.currentUser=n.user,r.isAdmin=!!n.user.isAdmin,{authenticated:!0,user:n.user}):{authenticated:!1}}catch(e){return console.error("Authentication check failed:",e),{authenticated:!1}}}async function de(e,t,n){try{const o=await(await fetch("/api/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:e,password:t,rememberMe:n})})).json();return o.success?(r.currentUser=o.user,r.isAdmin=!!o.user.isAdmin,o.csrfToken&&(r.csrfToken=o.csrfToken),{success:!0,user:o.user}):{success:!1,error:o.error||"Login failed"}}catch(s){return console.error(s),{success:!1,error:"Login failed. Please try again."}}}async function ue(){try{return await fetch("/api/auth/logout",{method:"POST",headers:csrfToken?{"X-CSRF-Token":csrfToken}:{}}),currentUser=null,csrfToken=null,{success:!0}}catch(e){return console.error("Logout failed:",e),{success:!1}}}async function me(e=!1){try{const t=new URLSearchParams({days:r.historyDays});r.showAll&&t.set("showAll","true"),e&&t.set("_t",Date.now());const n=await fetch(`/api/history/recent?${t}`);if(!n.ok)throw new Error(`HTTP ${n.status}`);return{success:!0,history:(await n.json()).history||[]}}catch(t){return console.error("[History] Load error:",t),{success:!1,error:"Failed to load history."}}}async function he(e){try{const t=await fetch("/api/dashboard/blocklist-search",{method:"POST",headers:{"Content-Type":"application/json","X-CSRF-Token":csrfToken},body:JSON.stringify({arrQueueId:e.arrQueueId,arrType:e.arrType,arrInstanceUrl:e.arrInstanceUrl,arrInstanceKey:e.arrInstanceKey,arrContentId:e.arrContentId,arrContentType:e.arrContentType})});if(!t.ok){const n=await t.json().catch(()=>({}));throw new Error(n.error||`HTTP ${t.status}`)}return{success:!0}}catch(t){throw console.error("[Blocklist] Error:",t),t}}async function pe(){try{return(await(await fetch("/health")).json()).version||null}catch{return null}}async function ge(){try{const e=await fetch("/api/dashboard/webhook-metrics");return e.ok?await e.json():null}catch{return null}}async function x(){var e,t;try{const n=ge();let s=!1,o={onGrab:!1,onDownload:!1,onImport:!1,onUpgrade:!1};try{const a=await fetch("/api/sonarr/notifications");if(a.ok){const c=(await a.json()).find(m=>m.name==="Sofarr");s=!!c,c&&(o={onGrab:c.onGrab,onDownload:c.onDownload,onImport:c.onImport,onUpgrade:c.onUpgrade})}}catch{}let d=!1,u={onGrab:!1,onDownload:!1,onImport:!1,onUpgrade:!1};try{const a=await fetch("/api/radarr/notifications");if(a.ok){const c=(await a.json()).find(m=>m.name==="Sofarr");d=!!c,c&&(u={onGrab:c.onGrab,onDownload:c.onDownload,onImport:c.onImport,onUpgrade:c.onUpgrade})}}catch{}r.webhookMetrics=await n;const h=r.webhookMetrics?Object.entries(r.webhookMetrics.instances||{}):[],l=((e=h.find(([a])=>a.includes("sonarr")))==null?void 0:e[1])||null,g=((t=h.find(([a])=>a.includes("radarr")))==null?void 0:t[1])||null;return r.sonarrWebhook={enabled:s,triggers:o,stats:l},r.radarrWebhook={enabled:d,triggers:u,stats:g},{success:!0}}catch(n){return console.error("Failed to fetch webhook status:",n),{success:!1}}}async function fe(){try{if(!(await fetch("/api/sonarr/notifications/sofarr-webhook",{method:"POST",headers:{"X-CSRF-Token":csrfToken||""}})).ok)throw new Error("Failed to enable");return await x(),{success:!0}}catch(e){return console.error("Failed to enable Sonarr webhook:",e),{success:!1,error:e.message}}}async function ye(){try{if(!(await fetch("/api/radarr/notifications/sofarr-webhook",{method:"POST",headers:{"X-CSRF-Token":r.csrfToken||""}})).ok)throw new Error("Failed to enable");return await x(),{success:!0}}catch(e){return console.error("Failed to enable Radarr webhook:",e),{success:!1,error:e.message}}}async function be(){try{const e=await fetch("/api/sonarr/notifications");if(!e.ok)throw new Error("Failed to fetch notifications");const n=(await e.json()).find(o=>o.name==="Sofarr");if(!n)throw new Error("Sofarr webhook not found");if(!(await fetch("/api/sonarr/notifications/test",{method:"POST",headers:{"Content-Type":"application/json","X-CSRF-Token":r.csrfToken||""},body:JSON.stringify(n)})).ok)throw new Error("Test failed");return await x(),{success:!0}}catch(e){return console.error("Failed to test Sonarr webhook:",e),{success:!1,error:e.message}}}async function ve(){try{const e=await fetch("/api/radarr/notifications");if(!e.ok)throw new Error("Failed to fetch notifications");const n=(await e.json()).find(o=>o.name==="Sofarr");if(!n)throw new Error("Sofarr webhook not found");if(!(await fetch("/api/radarr/notifications/test",{method:"POST",headers:{"Content-Type":"application/json","X-CSRF-Token":r.csrfToken||""},body:JSON.stringify(n)})).ok)throw new Error("Test failed");return await x(),{success:!0}}catch(e){return console.error("Failed to test Radarr webhook:",e),{success:!1,error:e.message}}}async function Ee(){try{const e=await fetch("/api/dashboard/status");if(!e.ok)throw new Error("Failed to fetch status: "+e.status);return{success:!0,data:await e.json()}}catch(e){return console.error("[Status] Error fetching status:",e),{success:!1,error:e.message}}}function ke(e){if(!e)return"N/A";if(typeof e=="string")return e;const t=["B","KB","MB","GB","TB"],n=Math.floor(Math.log(e)/Math.log(1024));return Math.round(e/Math.pow(1024,n)*100)/100+" "+t[n]}function we(e){if(!e||e===0)return"0 B/s";const t=["B/s","KB/s","MB/s","GB/s"];let n=e,s=0;for(;n>=1024&&s{const o="S"+String(s.season).padStart(2,"0")+"E"+String(s.episode).padStart(2,"0");return s.title?o+" β€” "+s.title:o});t.setAttribute("data-tooltip",n.join(` +`))}return t}function K(){const e=document.getElementById("downloads-list"),t=document.getElementById("no-downloads");let n=r.downloads;if(r.selectedDownloadClients.length>0){const d=r.selectedDownloadClients.map(u=>r.downloadClients[u]).filter(Boolean);n=r.downloads.filter(u=>d.some(h=>h.type===u.client&&h.id===u.instanceId))}if(r.downloadClients.length>0){const d=new Map(r.downloadClients.map((u,h)=>[u.id,h]));n=[...n].sort((u,h)=>{const l=d.get(u.instanceId)??1/0,g=d.get(h.instanceId)??1/0;return l-g})}if(n.length===0){t.style.display="block",e.innerHTML="";return}t.style.display="none";const s=new Map;e.querySelectorAll(".download-card").forEach(d=>{s.set(d.dataset.id,d)});const o=new Set;n.forEach(d=>{const u=d.title;o.add(u);const h=s.get(u);if(h)Se(h,d);else{const l=Be(d);e.appendChild(l)}}),s.forEach((d,u)=>{o.has(u)||d.remove()})}function Se(e,t){const n=e.querySelector(".download-header-right");n&&n.remove(),e.querySelectorAll(".download-header .download-user-badge").forEach(i=>i.remove());const o=e.querySelector(".download-header .download-client-logo-wrapper");o&&o.remove();const d=e.querySelector(".download-card-logo-wrapper");d&&d.remove();const u=e.querySelector(".download-header");if(u&&!u.querySelector(".download-header-right")){const i=document.createElement("div");if(i.className="download-header-right",r.showAll&&t.tagBadges&&t.tagBadges.length>0){const c=t.tagBadges.filter(f=>!f.matchedUser),m=t.tagBadges.filter(f=>f.matchedUser);for(const f of c){const y=document.createElement("span");y.className="download-user-badge unmatched",y.textContent=f.label,i.appendChild(y)}for(const f of m){const y=document.createElement("span");y.className="download-user-badge",y.textContent=f.matchedUser,i.appendChild(y)}}else if(t.matchedUserTag){const c=document.createElement("span");c.className="download-user-badge",c.textContent=t.matchedUserTag,i.appendChild(c)}u.appendChild(i)}if(t.client&&!e.querySelector(".download-card-logo-wrapper")){const i=document.createElement("span");i.className="download-client-logo-wrapper download-card-logo-wrapper";const c=document.createElement("img");c.className="download-client-logo",c.src=`/images/clients/${t.client}.svg`,c.alt=`${t.instanceName||t.client} icon`,c.title=t.instanceName||t.client,c.onerror=()=>{i.textContent=t.client.charAt(0).toUpperCase(),i.classList.add("fallback")},i.appendChild(c),e.appendChild(i)}const h=e.querySelector(".download-status");h&&h.textContent!==t.status&&(h.textContent=t.status,h.className=`download-status ${t.status}`);const l=e.querySelector(".progress-container");if(l&&t.progress!==void 0){const i=l.querySelector(".progress-bar"),c=l.querySelector(".progress-text"),m=l.querySelector(".missing-text");if(i){const f=i.querySelector(".downloaded");f&&(f.style.width=t.progress+"%")}if(c&&(c.textContent=t.progress+"%"),m){const f=parseFloat(t.mb)||parseFloat(t.size),y=parseFloat(t.mbmissing)||0;y>0&&f>0?m.textContent=`(missing ${y.toFixed(1)} of ${f.toFixed(1)} MB)`:m.textContent=""}}const g=e.querySelector('.detail-item[data-label="Speed"] .detail-value');g&&t.speed!==void 0&&(g.textContent=t.speed);const a=e.querySelector('.detail-item[data-label="ETA"] .detail-value');if(a&&t.eta!==void 0&&(a.textContent=t.eta),t.qbittorrent){const i=e.querySelector('.detail-item[data-label="Seeds"] .detail-value');i&&t.seeds!==void 0&&(i.textContent=t.seeds);const c=e.querySelector('.detail-item[data-label="Peers"] .detail-value');c&&t.peers!==void 0&&(c.textContent=t.peers);const m=e.querySelector('.detail-item[data-label="Availability"]');m&&t.availability!==void 0&&(m.querySelector(".detail-value").textContent=`${t.availability}%`,m.classList.toggle("availability-warning",parseFloat(t.availability)<100))}}async function Ie(e,t){if(confirm(`Blocklist "${t.title}" and trigger a new search? + +This will: +β€’ Remove the download from the download client +β€’ Add this release to the blocklist +β€’ Trigger an automatic search for a new release`)){e.disabled=!0,e.textContent="⏳ Working…";try{await he(t),e.textContent="βœ“ Done β€” searching…",e.className="blocklist-search-btn success"}catch(n){console.error("[Blocklist] Error:",n),e.disabled=!1,e.textContent="β›” Blocklist & Search",e.className="blocklist-search-btn error",e.title=`Failed: ${n.message}`,setTimeout(()=>{e.className="blocklist-search-btn",e.title="Remove this release from the download client, add it to the blocklist, and trigger a new automatic search"},4e3)}}}function Be(e){const t=document.createElement("div");if(t.className=`download-card ${e.type}`,t.dataset.id=e.title,e.coverArt){const a=document.createElement("div");a.className="download-cover";const i=document.createElement("img");i.src=e.coverArt?"/api/dashboard/cover-art?url="+encodeURIComponent(e.coverArt):"",i.alt=e.movieName||e.seriesName||e.title,i.loading="lazy",a.appendChild(i),t.appendChild(a)}const n=document.createElement("div");n.className="download-info";const s=document.createElement("div");s.className="download-header";const o=document.createElement("span");if(o.className=`download-type ${e.type}`,e.type==="series")o.textContent="πŸ“Ί Series";else if(e.type==="movie")o.textContent="🎬 Movie";else if(e.type==="torrent"){const a=e.instanceName?` (${e.instanceName})`:"";o.textContent=`πŸ“₯ Torrent${a}`}else o.textContent=e.type;const d=document.createElement("span");if(d.className=`download-status ${e.status}`,d.textContent=e.status,s.appendChild(o),s.appendChild(d),e.importIssues&&e.importIssues.length>0){const a=document.createElement("span");a.className="import-issue-badge",a.textContent="Import Pending",a.setAttribute("data-tooltip",e.importIssues.join(` +`)),s.appendChild(a)}if((r.isAdmin||e.canBlocklist)&&e.arrQueueId){const a=document.createElement("button");a.className="blocklist-search-btn",a.textContent="β›” Blocklist & Search",a.title="Remove this release from the download client, add it to the blocklist, and trigger a new automatic search",a.addEventListener("click",()=>Ie(a,e)),s.appendChild(a)}const u=document.createElement("div");if(u.className="download-header-right",r.showAll&&e.tagBadges&&e.tagBadges.length>0){const a=e.tagBadges.filter(c=>!c.matchedUser),i=e.tagBadges.filter(c=>c.matchedUser);for(const c of a){const m=document.createElement("span");m.className="download-user-badge unmatched",m.textContent=c.label,u.appendChild(m)}for(const c of i){const m=document.createElement("span");m.className="download-user-badge",m.textContent=c.matchedUser,u.appendChild(m)}}else if(e.matchedUserTag){const a=document.createElement("span");a.className="download-user-badge",a.textContent=e.matchedUserTag,u.appendChild(a)}if(s.appendChild(u),e.client){const a=document.createElement("span");a.className="download-client-logo-wrapper download-card-logo-wrapper";const i=document.createElement("img");i.className="download-client-logo",i.src=`/images/clients/${e.client}.svg`,i.alt=`${e.instanceName||e.client} icon`,i.title=e.instanceName||e.client,i.onerror=()=>{a.textContent=e.client.charAt(0).toUpperCase(),a.classList.add("fallback")},a.appendChild(i),t.appendChild(a)}const h=document.createElement("h3");if(h.className="download-title",h.textContent=e.title,n.appendChild(s),n.appendChild(h),e.seriesName){const a=document.createElement("p");a.className="download-series",r.isAdmin&&e.arrLink?a.innerHTML='Series: '+v(e.seriesName)+"":a.textContent=`Series: ${e.seriesName}`,n.appendChild(a);const i=J(e.episodes);i&&n.appendChild(i)}if(e.movieName){const a=document.createElement("p");a.className="download-movie",r.isAdmin&&e.arrLink?a.innerHTML='Movie: '+v(e.movieName)+"":a.textContent=`Movie: ${e.movieName}`,n.appendChild(a)}if(r.showAll&&e.tagBadges&&e.tagBadges.length>0){const a=e.tagBadges.filter(c=>!c.matchedUser),i=e.tagBadges.filter(c=>c.matchedUser);for(const c of a){const m=document.createElement("span");m.className="download-user-badge unmatched",m.textContent=c.label,s.appendChild(m)}for(const c of i){const m=document.createElement("span");m.className="download-user-badge",m.textContent=c.matchedUser,s.appendChild(m)}}else if(e.matchedUserTag){const a=document.createElement("span");a.className="download-user-badge",a.textContent=e.matchedUserTag,s.appendChild(a)}if(e.client){const a=document.createElement("span");a.className="download-client-logo-wrapper download-card-logo-wrapper";const i=document.createElement("img");i.className="download-client-logo",i.src=`/images/clients/${e.client}.svg`,i.alt=`${e.instanceName||e.client} icon`,i.title=e.instanceName||e.client,i.onerror=()=>{a.textContent=e.client.charAt(0).toUpperCase(),a.classList.add("fallback")},a.appendChild(i),s.appendChild(a)}const l=document.createElement("div");l.className="download-details";const g=S("Size",ke(e.size));if(l.appendChild(g),e.progress!==void 0){const a=document.createElement("div");a.className="detail-item progress-item",a.dataset.label="Progress";const i=document.createElement("span");i.className="detail-label",i.textContent="Progress";const c=document.createElement("div");c.className="progress-container";const m=parseFloat(e.mb)||parseFloat(e.size),f=parseFloat(e.mbmissing)||0,y=parseFloat(e.progress)||0,I=document.createElement("div");if(I.className="progress-bar",y>0){const b=document.createElement("div");b.className="progress-segment downloaded",b.style.width=y+"%",I.appendChild(b)}c.appendChild(I);const B=document.createElement("span");if(B.className="progress-text",B.textContent=e.progress+"%",c.appendChild(B),e.client&&(e.client==="qbittorrent"||e.client==="rtorrent")&&f>0&&m>0){const b=document.createElement("span");b.className="missing-text",b.textContent=`(missing ${f.toFixed(1)} of ${m.toFixed(1)} MB)`,c.appendChild(b)}a.appendChild(i),a.appendChild(c),l.appendChild(a)}if(e.speed&&e.speed>0){const a=S("Speed",we(e.speed));l.appendChild(a)}if(e.eta){const a=S("ETA",e.eta);l.appendChild(a)}if(e.qbittorrent){if(e.seeds!==void 0){const a=S("Seeds",e.seeds);l.appendChild(a)}if(e.peers!==void 0){const a=S("Peers",e.peers);l.appendChild(a)}if(e.availability!==void 0){const a=S("Availability",`${e.availability}%`);parseFloat(e.availability)<100&&a.classList.add("availability-warning"),l.appendChild(a)}}if(e.completedAt){const a=S("Completed",Te(e.completedAt));l.appendChild(a)}if(r.isAdmin&&(e.downloadPath||e.targetPath)){const a=document.createElement("div");if(a.className="download-paths",e.downloadPath){const i=document.createElement("div");i.className="path-item",i.innerHTML='Download: '+v(e.downloadPath)+"",a.appendChild(i)}if(e.targetPath){const i=document.createElement("div");i.className="path-item",i.innerHTML='Target: '+v(e.targetPath)+"",a.appendChild(i)}l.appendChild(a)}return n.appendChild(l),t.appendChild(n),t}function S(e,t){const n=document.createElement("div");n.className="detail-item",n.dataset.label=e;const s=document.createElement("span");s.className="detail-label",s.textContent=e;const o=document.createElement("span");return o.className="detail-value",o.textContent=t,n.appendChild(s),n.appendChild(o),n}function Te(e){return e?new Date(e).toLocaleString():"N/A"}function M(){X();const e=r.showAll?"?showAll=true":"",t=new EventSource("/api/dashboard/stream"+e);r.sseSource=t;let n=!0;t.onmessage=s=>{try{const o=JSON.parse(s.data);if(r.currentUser=o.user,r.isAdmin=!!o.isAdmin,r.downloads=o.downloads,o.downloadClients){r.downloadClients=o.downloadClients;const d=new CustomEvent("downloadClientsUpdated");document.dispatchEvent(d)}document.getElementById("currentUser").textContent=r.currentUser||"-",K(),et(),n&&(n=!1,tt())}catch(o){console.error("[SSE] Failed to parse message:",o)}},t.onerror=()=>{console.warn("[SSE] Connection lost, browser will retry...")},console.log("[SSE] Stream connected")}function X(){r.sseSource&&(r.sseSource.close(),r.sseSource=null,console.log("[SSE] Stream closed"))}function Ne(e){r.showAll=e,M();const t=new CustomEvent("historyReload");document.dispatchEvent(t)}(function(){const t=localStorage.getItem("sofarr-download-client");if(t&&t!=="all")try{r.selectedDownloadClients=[t],localStorage.setItem("sofarr-download-clients",JSON.stringify(r.selectedDownloadClients)),localStorage.removeItem("sofarr-download-client")}catch(n){console.error("[Migration] Failed to migrate download client filter:",n)}else try{const n=localStorage.getItem("sofarr-download-clients");r.selectedDownloadClients=n?JSON.parse(n):[]}catch(n){console.error("[Migration] Failed to load download client filter:",n),r.selectedDownloadClients=[]}})();(function(){try{const t=localStorage.getItem("sofarr-history-days");t&&(r.historyDays=parseInt(t,10)||7)}catch(t){console.error("[Storage] Failed to load history days:",t)}})();(function(){try{const t=localStorage.getItem("sofarr-ignore-available");r.ignoreAvailable=t==="true"}catch(t){console.error("[Storage] Failed to load ignore available:",t)}})();function xe(e){localStorage.setItem("sofarr-history-days",e)}function Le(e){localStorage.setItem("sofarr-ignore-available",e)}function De(e){localStorage.setItem("sofarr-download-clients",JSON.stringify(e))}function Q(){return localStorage.getItem("sofarr-theme")||"light"}function Ae(e){localStorage.setItem("sofarr-theme",e)}function Me(){return localStorage.getItem("sofarr-active-tab")||"downloads"}function O(e){localStorage.setItem("sofarr-active-tab",e)}function $e(){const e=document.getElementById("history-days"),t=document.getElementById("history-refresh-btn"),n=document.getElementById("ignore-available-toggle");e&&e.addEventListener("change",()=>{const s=parseInt(e.value,10);s>0&&s<=90&&(historyDays=s,xe(s),N(!0))}),t&&t.addEventListener("click",()=>N(!0)),n&&(n.checked=ignoreAvailable,n.addEventListener("change",()=>{ignoreAvailable=n.checked,Le(ignoreAvailable),Y(lastHistoryItems)})),document.addEventListener("historyReload",()=>{N(!0)})}function Re(){V(),r.historyRefreshHandle=setInterval(()=>N(),le)}function V(){r.historyRefreshHandle&&(clearInterval(r.historyRefreshHandle),r.historyRefreshHandle=null)}function Fe(){r.lastHistoryItems=[],document.getElementById("history-list").innerHTML="",document.getElementById("no-history").style.display="none",document.getElementById("history-error").style.display="none"}async function N(e=!1){document.getElementById("history-list");const t=document.getElementById("history-loading"),n=document.getElementById("history-error"),s=document.getElementById("no-history");t.style.display="block",n.style.display="none",s.style.display="none";try{const o=await me(e);t.style.display="none",o.success?(r.lastHistoryItems=o.history,Y(r.lastHistoryItems)):(n.textContent=o.error||"Failed to load history.",n.style.display="block")}catch(o){t.style.display="none",n.textContent="Failed to load history.",n.style.display="block",console.error("[History] Load error:",o)}}function Y(e){const t=document.getElementById("history-list"),n=document.getElementById("no-history");t.innerHTML="";const s=r.ignoreAvailable?e.filter(o=>!(o.outcome==="failed"&&o.availableForUpgrade)):e;if(!s.length){n.style.display="block";return}n.style.display="none",s.forEach(o=>t.appendChild(He(o)))}function He(e){const t=document.createElement("div");if(t.className=`history-card ${e.type} ${e.outcome}`,e.coverArt){const l=document.createElement("div");l.className="history-cover";const g=document.createElement("img");g.src="/api/dashboard/cover-art?url="+encodeURIComponent(e.coverArt),g.alt=e.movieName||e.seriesName||e.title,g.loading="lazy",l.appendChild(g),t.appendChild(l)}const n=document.createElement("div");n.className="history-info";const s=document.createElement("div");s.className="history-card-header";const o=document.createElement("span");o.className=`history-type-badge ${e.type}`,o.textContent=e.type==="series"?"πŸ“Ί Series":"🎬 Movie",s.appendChild(o);const d=document.createElement("span");if(d.className=`history-outcome-badge ${e.outcome}`,d.textContent=e.outcome==="imported"?"βœ“ Imported":"βœ— Failed",s.appendChild(d),e.availableForUpgrade){const l=document.createElement("span");l.className="history-upgrade-badge",l.title="A previous version of this item is available. An upgrade download has failed.",l.textContent="⬆ Available",s.appendChild(l)}if(e.instanceName){const l=document.createElement("span");l.className="history-instance-badge",l.textContent=e.instanceName,s.appendChild(l)}if(r.showAll&&e.tagBadges&&e.tagBadges.length>0){const l=e.tagBadges.filter(a=>!a.matchedUser),g=e.tagBadges.filter(a=>a.matchedUser);for(const a of l){const i=document.createElement("span");i.className="download-user-badge unmatched",i.textContent=a.label,s.appendChild(i)}for(const a of g){const i=document.createElement("span");i.className="download-user-badge",i.textContent=a.matchedUser,s.appendChild(i)}}else if(e.matchedUserTag){const l=document.createElement("span");l.className="download-user-badge",l.textContent=e.matchedUserTag,s.appendChild(l)}n.appendChild(s);const u=document.createElement("h3");if(u.className="history-title",u.textContent=e.title,n.appendChild(u),e.seriesName){const l=document.createElement("p");l.className="history-media-name",r.isAdmin&&e.arrLink?l.innerHTML='Series: '+v(e.seriesName)+"":l.textContent="Series: "+e.seriesName,n.appendChild(l);const g=J(e.episodes);g&&n.appendChild(g)}if(e.movieName){const l=document.createElement("p");l.className="history-media-name",r.isAdmin&&e.arrLink?l.innerHTML='Movie: '+v(e.movieName)+"":l.textContent="Movie: "+e.movieName,n.appendChild(l)}const h=document.createElement("div");if(h.className="history-details",e.completedAt&&h.appendChild(j("Completed",Ce(e.completedAt))),e.quality&&h.appendChild(j("Quality",e.quality)),e.outcome==="failed"&&e.failureMessage){const l=document.createElement("div");l.className="history-failure-message",l.textContent=e.failureMessage,h.appendChild(l)}return n.appendChild(h),t.appendChild(n),t}function j(e,t){const n=document.createElement("div");n.className="detail-item",n.dataset.label=e;const s=document.createElement("span");s.className="detail-label",s.textContent=e;const o=document.createElement("span");return o.className="detail-value",o.textContent=t,n.appendChild(s),n.appendChild(o),n}function Ue(){document.getElementById("webhooks-section")&&(document.getElementById("webhooks-header").addEventListener("click",We),document.getElementById("enable-sonarr-webhook").addEventListener("click",qe),document.getElementById("enable-radarr-webhook").addEventListener("click",Oe),document.getElementById("test-sonarr-webhook").addEventListener("click",je),document.getElementById("test-radarr-webhook").addEventListener("click",Ge))}function We(){r.webhookSectionExpanded=!r.webhookSectionExpanded;const e=document.getElementById("webhooks-content"),t=document.getElementById("webhooks-toggle");e.style.display=r.webhookSectionExpanded?"":"none",t.classList.toggle("expanded",r.webhookSectionExpanded),r.webhookSectionExpanded&&Z()}async function Z(){const e=document.getElementById("webhook-loading");e.style.display="";try{(await x()).success&&Pe()}catch(t){console.error("Failed to fetch webhook status:",t)}finally{e.style.display="none"}}function Pe(){const e=document.getElementById("sonarr-status"),t=document.getElementById("enable-sonarr-webhook"),n=document.getElementById("test-sonarr-webhook"),s=document.getElementById("sonarr-triggers"),o=document.getElementById("sonarr-stats");e.textContent=sonarrWebhook.enabled?"● Enabled":"β—‹ Disabled",e.className="status-indicator "+(sonarrWebhook.enabled?"enabled":"disabled"),t.style.display=sonarrWebhook.enabled?"none":"",n.style.display=sonarrWebhook.enabled?"":"none",s.style.display=sonarrWebhook.enabled?"":"none",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")),sonarrWebhook.stats?(o.style.display="",document.getElementById("sonarr-events").textContent=sonarrWebhook.stats.eventsReceived??0,document.getElementById("sonarr-polls").textContent=sonarrWebhook.stats.pollsSkipped??0,document.getElementById("sonarr-last").textContent=q(sonarrWebhook.stats.lastWebhookTimestamp)):o.style.display="none";const d=document.getElementById("radarr-status"),u=document.getElementById("enable-radarr-webhook"),h=document.getElementById("test-radarr-webhook"),l=document.getElementById("radarr-triggers"),g=document.getElementById("radarr-stats");d.textContent=radarrWebhook.enabled?"● Enabled":"β—‹ Disabled",d.className="status-indicator "+(radarrWebhook.enabled?"enabled":"disabled"),u.style.display=radarrWebhook.enabled?"none":"",h.style.display=radarrWebhook.enabled?"":"none",l.style.display=radarrWebhook.enabled?"":"none",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")),radarrWebhook.stats?(g.style.display="",document.getElementById("radarr-events").textContent=radarrWebhook.stats.eventsReceived??0,document.getElementById("radarr-polls").textContent=radarrWebhook.stats.pollsSkipped??0,document.getElementById("radarr-last").textContent=q(radarrWebhook.stats.lastWebhookTimestamp)):g.style.display="none"}async function qe(){k(!0);try{const e=await fe();e.success||(console.error("Failed to enable Sonarr webhook:",e.error),alert("Failed to enable Sonarr webhook. Check console for details."))}catch(e){console.error("Failed to enable Sonarr webhook:",e),alert("Failed to enable Sonarr webhook. Check console for details.")}finally{k(!1)}}async function Oe(){k(!0);try{const e=await ye();e.success||(console.error("Failed to enable Radarr webhook:",e.error),alert("Failed to enable Radarr webhook. Check console for details."))}catch(e){console.error("Failed to enable Radarr webhook:",e),alert("Failed to enable Radarr webhook. Check console for details.")}finally{k(!1)}}async function je(){k(!0);try{const e=await be();e.success?alert("Sonarr webhook test sent successfully!"):(console.error("Failed to test Sonarr webhook:",e.error),alert("Failed to test Sonarr webhook. Check console for details."))}catch(e){console.error("Failed to test Sonarr webhook:",e),alert("Failed to test Sonarr webhook. Check console for details.")}finally{k(!1)}}async function Ge(){k(!0);try{const e=await ve();e.success?alert("Radarr webhook test sent successfully!"):(console.error("Failed to test Radarr webhook:",e.error),alert("Failed to test Radarr webhook. Check console for details."))}catch(e){console.error("Failed to test Radarr webhook:",e),alert("Failed to test Radarr webhook. Check console for details.")}finally{k(!1)}}function k(e){r.webhookLoading=e,document.getElementById("enable-sonarr-webhook").disabled=e,document.getElementById("enable-radarr-webhook").disabled=e,document.getElementById("test-sonarr-webhook").disabled=e,document.getElementById("test-radarr-webhook").disabled=e,document.getElementById("webhook-loading").style.display=e?"":"none"}async function _e(){const e=document.getElementById("status-panel"),t=document.getElementById("webhooks-section");if(e.style.display!=="none"){e.style.display="none",t&&(t.style.display="none"),r.statusRefreshHandle&&(clearInterval(r.statusRefreshHandle),r.statusRefreshHandle=null);return}e.style.display="block",t&&r.isAdmin?(t.style.display="block",r.webhookSectionExpanded=!1,document.getElementById("webhooks-content").style.display="none",document.getElementById("webhooks-toggle").classList.remove("expanded"),await Z()):t&&(t.style.display="none"),G(),r.statusRefreshHandle&&clearInterval(r.statusRefreshHandle),r.statusRefreshHandle=setInterval(G,ie)}function ze(){document.getElementById("status-panel").style.display="none";const e=document.getElementById("webhooks-section");e&&(e.style.display="none"),r.statusRefreshHandle&&(clearInterval(r.statusRefreshHandle),r.statusRefreshHandle=null)}async function G(){var n;const e=document.getElementById("status-panel"),t=document.getElementById("status-content");if(console.log("[Status] panel found:",!!e,"contentDiv found:",!!t,"panel display:",(n=e==null?void 0:e.style)==null?void 0:n.display),!(!e||e.style.display==="none")){console.log("[Status] Refreshing status panel...");try{const s=await Ee();s.success&&(console.log("[Status] Got status data, rendering..."),Je(s.data,e))}catch(s){console.error("[Status] Error fetching status:",s),t&&(!t.innerHTML||t.innerHTML.includes("status-loading"))&&(t.innerHTML='

Failed to load status: '+s.message+"

")}}}function Je(e,t){var B,b,$,R,F,H,U,W,P;console.log("[Status] renderStatusPanel called with data:",e?"yes":"no","keys:",e?Object.keys(e):"none");const n=e.server,s=Math.floor(n.uptimeSeconds/3600),o=Math.floor(n.uptimeSeconds%3600/60),d=n.uptimeSeconds%60,u=`${s}h ${o}m ${d}s`,h=(e.cache.totalSizeBytes/1024).toFixed(1);let l=`

Server Status

@@ -1052,824 +13,30 @@ function renderStatusPanel(data, panel) {
Server
-
Uptime${uptime}
-
Node${escapeHtml(s.nodeVersion)}
-
Memory (RSS)${s.memoryUsageMB} MB
-
Heap${s.heapUsedMB} / ${s.heapTotalMB} MB
+
Uptime${u}
+
Node${L(n.nodeVersion)}
+
Memory (RSS)${n.memoryUsageMB} MB
+
Heap${n.heapUsedMB} / ${n.heapTotalMB} MB
-
Data Refresh
`; - - const pollIntervalMs = data.polling.intervalMs; - const clients = data.clients || []; - const sseClients = clients.filter(c => c.type === 'sse'); - - if (data.polling.enabled) { - html += `
Background poll${pollIntervalMs / 1000}s
`; - } else { - html += `
Background pollDisabled
`; - } - - const mode = sseClients.length > 0 - ? `SSE push` - : (data.polling.enabled ? 'Background' : 'On-demand (idle)'); - html += `
Delivery mode${mode}
`; - - html += `
SSE clients${sseClients.length}
`; - for (const c of sseClients) { - const age = Math.round((Date.now() - c.connectedAt) / 1000); - html += `
${escapeHtml(c.user)}connected ${age}s ago
`; - } - - html += `
`; - - // Webhook metrics card (admin only) - if (isAdmin && data.webhooks) { - const wh = data.webhooks; - const sonarrEnabled = wh.sonarr?.enabled ? '●' : 'β—‹'; - const radarrEnabled = wh.radarr?.enabled ? '●' : 'β—‹'; - const sonarrEvents = wh.sonarr?.eventsReceived || 0; - const radarrEvents = wh.radarr?.eventsReceived || 0; - const sonarrPolls = wh.sonarr?.pollsSkipped || 0; - const radarrPolls = wh.radarr?.pollsSkipped || 0; - - html += ` +
Data Refresh
`;const g=e.polling.intervalMs,i=(e.clients||[]).filter(p=>p.type==="sse");e.polling.enabled?l+=`
Background poll${g/1e3}s
`:l+='
Background pollDisabled
';const c=i.length>0?'SSE push':e.polling.enabled?"Background":"On-demand (idle)";l+=`
Delivery mode${c}
`,l+=`
SSE clients${i.length}
`;for(const p of i){const w=Math.round((Date.now()-p.connectedAt)/1e3);l+=`
${L(p.user)}connected ${w}s ago
`}if(l+="
",r.isAdmin&&e.webhooks){const p=e.webhooks,w=(B=p.sonarr)!=null&&B.enabled?"●":"β—‹",E=(b=p.radarr)!=null&&b.enabled?"●":"β—‹",C=(($=p.sonarr)==null?void 0:$.eventsReceived)||0,se=((R=p.radarr)==null?void 0:R.eventsReceived)||0,ae=((F=p.sonarr)==null?void 0:F.pollsSkipped)||0,oe=((H=p.radarr)==null?void 0:H.pollsSkipped)||0;l+=`
Webhooks
-
Sonarr${sonarrEnabled} ${wh.sonarr?.enabled ? 'Enabled' : 'Disabled'}
-
Radarr${radarrEnabled} ${wh.radarr?.enabled ? 'Enabled' : 'Disabled'}
-
EventsS:${sonarrEvents} R:${radarrEvents}
-
Polls skippedS:${sonarrPolls} R:${radarrPolls}
-
`; - } - - // Poll timings card - const lp = data.polling.lastPoll; - if (lp) { - const pollAge = Math.round((Date.now() - new Date(lp.timestamp).getTime()) / 1000); - html += ` +
Sonarr${w} ${(U=p.sonarr)!=null&&U.enabled?"Enabled":"Disabled"}
+
Radarr${E} ${(W=p.radarr)!=null&&W.enabled?"Enabled":"Disabled"}
+
EventsS:${C} R:${se}
+
Polls skippedS:${ae} R:${oe}
+
`}const m=e.polling.lastPoll;if(m){const p=Math.round((Date.now()-new Date(m.timestamp).getTime())/1e3);l+=`
-
Last Poll (${lp.totalMs}ms total, ${pollAge}s ago)
-
`; - const maxTaskMs = lp.tasks.reduce((max, t) => Math.max(max, t.ms), 1); - for (const t of lp.tasks) { - const barWidth = Math.max(2, (t.ms / maxTaskMs) * 100); - html += ` +
Last Poll (${m.totalMs}ms total, ${p}s ago)
+
`;const w=m.tasks.reduce((E,C)=>Math.max(E,C.ms),1);for(const E of m.tasks){const C=Math.max(2,E.ms/w*100);l+=`
- ${escapeHtml(t.label)} -
- ${t.ms}ms -
`; - } - html += `
`; - } - - // Cache table - html += ` + ${L(E.label)} +
+ ${E.ms}ms +
`}l+=""}l+=`
-
Cache (${data.cache.entryCount} entries, ${totalKB} KB)
+
Cache (${e.cache.entryCount} entries, ${h} KB)
- `; - - for (const e of data.cache.entries) { - const sizeStr = e.sizeBytes > 1024 ? (e.sizeBytes / 1024).toFixed(1) + ' KB' : e.sizeBytes + ' B'; - const ttlStr = e.expired ? 'expired' : (e.ttlRemainingMs / 1000).toFixed(0) + 's'; - const items = e.itemCount !== null ? e.itemCount : 'β€”'; - html += ``; - } - - html += `
KeyItemsSizeTTL
${escapeHtml(e.key)}${items}${sizeStr}${ttlStr}
`; - // Render into status-content div, not the whole panel (preserves webhooks section) - const contentDiv = document.getElementById('status-content'); - const panelCheck = document.getElementById('status-panel'); - console.log('[Status] contentDiv found:', !!contentDiv, 'panel children:', panelCheck?.children?.length, 'HTML length:', html.length); - if (panelCheck) { - console.log('[Status] panel innerHTML preview:', panelCheck.innerHTML.substring(0, 200)); - } - if (contentDiv) { - contentDiv.innerHTML = html; - console.log('[Status] HTML rendered, contentDiv innerHTML length:', contentDiv.innerHTML.length); - } else { - console.error('[Status] contentDiv not found!'); - } - // Wire close button β€” addEventListener avoids CSP inline handler restrictions - const closeBtn = document.getElementById('status-close-btn'); - if (closeBtn) closeBtn.addEventListener('click', closeStatusPanel); - // Set bar widths via JS DOM assignment β€” immune to CSP style-src restrictions - panel.querySelectorAll('.timing-bar[data-w]').forEach(el => { - el.style.width = el.dataset.w + '%'; - }); -} - -function formatSize(size) { - if (!size) return 'N/A'; - // If already a formatted string (e.g., "21.5 GB"), return as-is - if (typeof size === 'string') { - return size; - } - // If it's a number (bytes), format it - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(size) / Math.log(1024)); - return Math.round(size / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; -} - -function formatDate(dateString) { - if (!dateString) return 'N/A'; - return new Date(dateString).toLocaleString(); -} - -function showError(message) { - const errorDiv = document.getElementById('error-message'); - errorDiv.textContent = message; - errorDiv.style.display = 'block'; -} - -function hideError() { - const errorDiv = document.getElementById('error-message'); - errorDiv.style.display = 'none'; -} - -function showLoading() { - const loading = document.getElementById('loading'); - loading.style.display = 'block'; -} - -function hideLoading() { - const loading = document.getElementById('loading'); - loading.style.display = 'none'; -} - -// ============================================================================= -// History section -// ============================================================================= - -function initHistoryControls() { - const daysInput = document.getElementById('history-days'); - const refreshBtn = document.getElementById('history-refresh-btn'); - const ignoreToggle = document.getElementById('ignore-available-toggle'); - if (daysInput) { - daysInput.addEventListener('change', () => { - const v = parseInt(daysInput.value, 10); - if (v > 0 && v <= 90) { - historyDays = v; - localStorage.setItem('sofarr-history-days', v); - loadHistory(); - } - }); - } - if (refreshBtn) { - refreshBtn.addEventListener('click', () => loadHistory(true)); - } - if (ignoreToggle) { - ignoreToggle.checked = ignoreAvailable; - ignoreToggle.addEventListener('change', () => { - ignoreAvailable = ignoreToggle.checked; - localStorage.setItem('sofarr-ignore-available', ignoreAvailable); - renderHistory(lastHistoryItems); - }); - } -} - -// ============================================================================= -// 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 = '
No clients available
'; - 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); -} - -function stopHistoryRefresh() { - if (historyRefreshHandle) { - clearInterval(historyRefreshHandle); - historyRefreshHandle = null; - } -} - -function clearHistory() { - lastHistoryItems = []; - document.getElementById('history-list').innerHTML = ''; - document.getElementById('no-history').style.display = 'none'; - document.getElementById('history-error').style.display = 'none'; -} - -async function loadHistory(forceRefresh = false) { - const listEl = document.getElementById('history-list'); - const loadingEl = document.getElementById('history-loading'); - const errorEl = document.getElementById('history-error'); - const noHistoryEl = document.getElementById('no-history'); - - loadingEl.style.display = 'block'; - errorEl.style.display = 'none'; - noHistoryEl.style.display = 'none'; - - try { - const params = new URLSearchParams({ days: historyDays }); - if (showAll) params.set('showAll', 'true'); - if (forceRefresh) params.set('_t', Date.now()); - const res = await fetch(`/api/history/recent?${params}`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json(); - loadingEl.style.display = 'none'; - lastHistoryItems = data.history || []; - renderHistory(lastHistoryItems); - } catch (err) { - loadingEl.style.display = 'none'; - errorEl.textContent = 'Failed to load history.'; - errorEl.style.display = 'block'; - console.error('[History] Load error:', err); - } -} - -function renderHistory(items) { - const listEl = document.getElementById('history-list'); - const noHistoryEl = document.getElementById('no-history'); - listEl.innerHTML = ''; - const visible = ignoreAvailable - ? items.filter(item => !(item.outcome === 'failed' && item.availableForUpgrade)) - : items; - if (!visible.length) { - noHistoryEl.style.display = 'block'; - return; - } - noHistoryEl.style.display = 'none'; - visible.forEach(item => listEl.appendChild(createHistoryCard(item))); -} - -function createHistoryCard(item) { - const card = document.createElement('div'); - card.className = `history-card ${item.type} ${item.outcome}`; - - if (item.coverArt) { - const coverDiv = document.createElement('div'); - coverDiv.className = 'history-cover'; - const img = document.createElement('img'); - img.src = '/api/dashboard/cover-art?url=' + encodeURIComponent(item.coverArt); - img.alt = item.movieName || item.seriesName || item.title; - img.loading = 'lazy'; - coverDiv.appendChild(img); - card.appendChild(coverDiv); - } - - const info = document.createElement('div'); - info.className = 'history-info'; - - // Header row: type badge + outcome badge - const header = document.createElement('div'); - header.className = 'history-card-header'; - - const typeBadge = document.createElement('span'); - typeBadge.className = `history-type-badge ${item.type}`; - typeBadge.textContent = item.type === 'series' ? 'πŸ“Ί Series' : '🎬 Movie'; - header.appendChild(typeBadge); - - const outcomeBadge = document.createElement('span'); - outcomeBadge.className = `history-outcome-badge ${item.outcome}`; - outcomeBadge.textContent = item.outcome === 'imported' ? 'βœ“ Imported' : 'βœ— Failed'; - header.appendChild(outcomeBadge); - - if (item.availableForUpgrade) { - const upgradeBadge = document.createElement('span'); - upgradeBadge.className = 'history-upgrade-badge'; - upgradeBadge.title = 'A previous version of this item is available. An upgrade download has failed.'; - upgradeBadge.textContent = '⬆ Available'; - header.appendChild(upgradeBadge); - } - - if (item.instanceName) { - const instBadge = document.createElement('span'); - instBadge.className = 'history-instance-badge'; - instBadge.textContent = item.instanceName; - header.appendChild(instBadge); - } - - if (showAll && item.tagBadges && item.tagBadges.length > 0) { - const unmatched = item.tagBadges.filter(b => !b.matchedUser); - const matched = item.tagBadges.filter(b => b.matchedUser); - for (const b of unmatched) { - const badge = document.createElement('span'); - badge.className = 'download-user-badge unmatched'; - badge.textContent = b.label; - header.appendChild(badge); - } - for (const b of matched) { - const badge = document.createElement('span'); - badge.className = 'download-user-badge'; - badge.textContent = b.matchedUser; - header.appendChild(badge); - } - } else if (item.matchedUserTag) { - const badge = document.createElement('span'); - badge.className = 'download-user-badge'; - badge.textContent = item.matchedUserTag; - header.appendChild(badge); - } - - info.appendChild(header); - - // Title - const title = document.createElement('h3'); - title.className = 'history-title'; - title.textContent = item.title; - info.appendChild(title); - - // Series/movie name with optional arr link - if (item.seriesName) { - const p = document.createElement('p'); - p.className = 'history-media-name'; - if (isAdmin && item.arrLink) { - p.innerHTML = 'Series: ' + escapeHtml(item.seriesName) + ''; - } else { - p.textContent = 'Series: ' + item.seriesName; - } - info.appendChild(p); - const epEl = formatEpisodeInfo(item.episodes); - if (epEl) info.appendChild(epEl); - } - if (item.movieName) { - const p = document.createElement('p'); - p.className = 'history-media-name'; - if (isAdmin && item.arrLink) { - p.innerHTML = 'Movie: ' + escapeHtml(item.movieName) + ''; - } else { - p.textContent = 'Movie: ' + item.movieName; - } - info.appendChild(p); - } - - // Detail pills - const details = document.createElement('div'); - details.className = 'history-details'; - - if (item.completedAt) { - details.appendChild(createDetailItem('Completed', formatDate(item.completedAt))); - } - if (item.quality) { - details.appendChild(createDetailItem('Quality', item.quality)); - } - - // Failed imports: show failure message - if (item.outcome === 'failed' && item.failureMessage) { - const failItem = document.createElement('div'); - failItem.className = 'history-failure-message'; - failItem.textContent = item.failureMessage; - details.appendChild(failItem); - } - - info.appendChild(details); - card.appendChild(info); - return card; -} - -// ===== Webhooks Configuration ===== -let webhookSectionExpanded = false; -let webhookLoading = false; -let sonarrWebhook = { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null }; -let radarrWebhook = { enabled: false, triggers: { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }, stats: null }; -let webhookMetrics = null; - -function initWebhooks() { - const webhooksSection = document.getElementById('webhooks-section'); - if (!webhooksSection) return; - - // Note: visibility is controlled by showDashboard() based on isAdmin - - 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('test-sonarr-webhook').addEventListener('click', testSonarrWebhook); - document.getElementById('test-radarr-webhook').addEventListener('click', testRadarrWebhook); -} - -function toggleWebhookSection() { - webhookSectionExpanded = !webhookSectionExpanded; - const content = document.getElementById('webhooks-content'); - const toggle = document.getElementById('webhooks-toggle'); - - content.style.display = webhookSectionExpanded ? '' : 'none'; - toggle.classList.toggle('expanded', webhookSectionExpanded); - - if (webhookSectionExpanded) { - fetchWebhookStatus(); - } -} - -async function fetchWebhookMetrics() { - try { - const res = await fetch('/api/dashboard/webhook-metrics'); - if (!res.ok) return null; - return await res.json(); - } catch (err) { - return null; - } -} - -async function fetchWebhookStatus() { - const loadingEl = document.getElementById('webhook-loading'); - loadingEl.style.display = ''; - - try { - // Fetch metrics in parallel - const metricsPromise = fetchWebhookMetrics(); - - // Fetch Sonarr notifications - let sonarrEnabled = false; - let sonarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }; - try { - const sonarrRes = await fetch('/api/sonarr/notifications'); - if (sonarrRes.ok) { - const sonarrData = await sonarrRes.json(); - const sonarrSofarr = sonarrData.find(n => n.name === 'Sofarr'); - sonarrEnabled = !!sonarrSofarr; - if (sonarrSofarr) { - sonarrTriggers = { - onGrab: sonarrSofarr.onGrab, - onDownload: sonarrSofarr.onDownload, - onImport: sonarrSofarr.onImport, - onUpgrade: sonarrSofarr.onUpgrade - }; - } - } - } catch (err) { - // Sonarr not configured - } - - // Fetch Radarr notifications - let radarrEnabled = false; - let radarrTriggers = { onGrab: false, onDownload: false, onImport: false, onUpgrade: false }; - try { - const radarrRes = await fetch('/api/radarr/notifications'); - if (radarrRes.ok) { - const radarrData = await radarrRes.json(); - const radarrSofarr = radarrData.find(n => n.name === 'Sofarr'); - radarrEnabled = !!radarrSofarr; - if (radarrSofarr) { - radarrTriggers = { - onGrab: radarrSofarr.onGrab, - onDownload: radarrSofarr.onDownload, - onImport: radarrSofarr.onImport, - onUpgrade: radarrSofarr.onUpgrade - }; - } - } - } catch (err) { - // Radarr not configured - } - - webhookMetrics = await metricsPromise; - - // Find instance stats - const instanceEntries = webhookMetrics ? Object.entries(webhookMetrics.instances || {}) : []; - const sonarrStats = instanceEntries.find(([url]) => url.includes('sonarr'))?.[1] || null; - const radarrStats = instanceEntries.find(([url]) => url.includes('radarr'))?.[1] || null; - - sonarrWebhook = { enabled: sonarrEnabled, triggers: sonarrTriggers, stats: sonarrStats }; - radarrWebhook = { enabled: radarrEnabled, triggers: radarrTriggers, stats: radarrStats }; - - renderWebhookStatus(); - } catch (err) { - console.error('Failed to fetch webhook status:', err); - } finally { - loadingEl.style.display = 'none'; - } -} - -function renderWebhookStatus() { - // Sonarr - const sonarrStatus = document.getElementById('sonarr-status'); - const sonarrEnableBtn = document.getElementById('enable-sonarr-webhook'); - const sonarrTestBtn = document.getElementById('test-sonarr-webhook'); - 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'); - sonarrEnableBtn.style.display = sonarrWebhook.enabled ? 'none' : ''; - sonarrTestBtn.style.display = sonarrWebhook.enabled ? '' : 'none'; - sonarrTriggers.style.display = sonarrWebhook.enabled ? '' : 'none'; - - 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 (sonarrWebhook.stats) { - sonarrStats.style.display = ''; - 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); - } else { - sonarrStats.style.display = 'none'; - } - - // Radarr - const radarrStatus = document.getElementById('radarr-status'); - const radarrEnableBtn = document.getElementById('enable-radarr-webhook'); - const radarrTestBtn = document.getElementById('test-radarr-webhook'); - 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'); - radarrEnableBtn.style.display = radarrWebhook.enabled ? 'none' : ''; - radarrTestBtn.style.display = radarrWebhook.enabled ? '' : 'none'; - radarrTriggers.style.display = radarrWebhook.enabled ? '' : 'none'; - - 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 (radarrWebhook.stats) { - radarrStats.style.display = ''; - 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); - } else { - radarrStats.style.display = 'none'; - } -} - -function formatTimeAgo(timestamp) { - if (!timestamp) return 'Never'; - const seconds = Math.floor((Date.now() - timestamp) / 1000); - if (seconds < 60) return seconds + 's ago'; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return minutes + 'm ago'; - const hours = Math.floor(minutes / 60); - if (hours < 24) return hours + 'h ago'; - return Math.floor(hours / 24) + 'd ago'; -} - -async function enableSonarrWebhook() { - setWebhookLoading(true); - try { - const res = await fetch('/api/sonarr/notifications/sofarr-webhook', { - method: 'POST', - headers: { 'X-CSRF-Token': csrfToken || '' } - }); - if (!res.ok) throw new Error('Failed to enable'); - await fetchWebhookStatus(); - } catch (err) { - console.error('Failed to enable Sonarr webhook:', err); - alert('Failed to enable Sonarr webhook. Check console for details.'); - } finally { - setWebhookLoading(false); - } -} - -async function enableRadarrWebhook() { - setWebhookLoading(true); - try { - const res = await fetch('/api/radarr/notifications/sofarr-webhook', { - method: 'POST', - headers: { 'X-CSRF-Token': csrfToken || '' } - }); - if (!res.ok) throw new Error('Failed to enable'); - await fetchWebhookStatus(); - } catch (err) { - console.error('Failed to enable Radarr webhook:', err); - alert('Failed to enable Radarr webhook. Check console for details.'); - } finally { - setWebhookLoading(false); - } -} - -async function testSonarrWebhook() { - setWebhookLoading(true); - try { - const sonarrRes = await fetch('/api/sonarr/notifications'); - if (!sonarrRes.ok) throw new Error('Failed to fetch notifications'); - const sonarrData = await sonarrRes.json(); - const sonarrSofarr = sonarrData.find(n => n.name === 'Sofarr'); - if (!sonarrSofarr) throw new Error('Sofarr webhook not found'); - - const res = await fetch('/api/sonarr/notifications/test', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': csrfToken || '' - }, - body: JSON.stringify(sonarrSofarr) - }); - if (!res.ok) throw new Error('Test failed'); - await fetchWebhookStatus(); - alert('Sonarr webhook test sent successfully!'); - } catch (err) { - console.error('Failed to test Sonarr webhook:', err); - alert('Failed to test Sonarr webhook. Check console for details.'); - } finally { - setWebhookLoading(false); - } -} - -async function testRadarrWebhook() { - setWebhookLoading(true); - try { - const radarrRes = await fetch('/api/radarr/notifications'); - if (!radarrRes.ok) throw new Error('Failed to fetch notifications'); - const radarrData = await radarrRes.json(); - const radarrSofarr = radarrData.find(n => n.name === 'Sofarr'); - if (!radarrSofarr) throw new Error('Sofarr webhook not found'); - - const res = await fetch('/api/radarr/notifications/test', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': csrfToken || '' - }, - body: JSON.stringify(radarrSofarr) - }); - if (!res.ok) throw new Error('Test failed'); - await fetchWebhookStatus(); - alert('Radarr webhook test sent successfully!'); - } catch (err) { - console.error('Failed to test Radarr webhook:', err); - alert('Failed to test Radarr webhook. Check console for details.'); - } finally { - setWebhookLoading(false); - } -} - -function setWebhookLoading(loading) { - webhookLoading = loading; - document.getElementById('enable-sonarr-webhook').disabled = loading; - document.getElementById('enable-radarr-webhook').disabled = loading; - document.getElementById('test-sonarr-webhook').disabled = loading; - document.getElementById('test-radarr-webhook').disabled = loading; - document.getElementById('webhook-loading').style.display = loading ? '' : 'none'; -} + `;for(const p of e.cache.entries){const w=p.sizeBytes>1024?(p.sizeBytes/1024).toFixed(1)+" KB":p.sizeBytes+" B",E=p.expired?'expired':(p.ttlRemainingMs/1e3).toFixed(0)+"s",C=p.itemCount!==null?p.itemCount:"β€”";l+=`${L(p.key)}${C}${w}${E}`}l+="";const f=document.getElementById("status-content"),y=document.getElementById("status-panel");console.log("[Status] contentDiv found:",!!f,"panel children:",(P=y==null?void 0:y.children)==null?void 0:P.length,"HTML length:",l.length),y&&console.log("[Status] panel innerHTML preview:",y.innerHTML.substring(0,200)),f?(f.innerHTML=l,console.log("[Status] HTML rendered, contentDiv innerHTML length:",f.innerHTML.length)):console.error("[Status] contentDiv not found!");const I=document.getElementById("status-close-btn");I&&I.addEventListener("click",ze),t.querySelectorAll(".timing-bar[data-w]").forEach(p=>{p.style.width=p.dataset.w+"%"})}function L(e){const t=document.createElement("div");return t.textContent=e,t.innerHTML}function Ke(){return new Promise(e=>{const t=document.getElementById("login-container");t.classList.add("fade-out"),t.addEventListener("transitionend",()=>{t.style.display="none",t.classList.remove("fade-out"),e()},{once:!0})})}function Xe(){const e=document.getElementById("splash-screen");e.style.display="flex",e.style.opacity="1",e.classList.remove("fade-out")}function D(e){return new Promise(t=>{const n=Date.now()-(e||0),s=Math.max(0,re-n);setTimeout(()=>{const o=document.getElementById("splash-screen");o.classList.add("fade-out");const u=setTimeout(()=>{o.style.display="none",t()},400+100);o.addEventListener("transitionend",()=>{clearTimeout(u),o.style.display="none",t()},{once:!0})},s)})}async function Qe(){const e=Date.now();try{(await ce()).authenticated?(ee(),te(),M(),await D(e)):(await D(e),A())}catch(t){console.error("Authentication check failed:",t),await D(e),A()}}async function Ve(e){e.preventDefault();const t=document.getElementById("username").value,n=document.getElementById("password").value,s=document.getElementById("remember-me").checked;try{const o=await de(t,n,s);if(o.success){await Ke(),Xe(),await new Promise(u=>requestAnimationFrame(()=>requestAnimationFrame(u))),ee(),te();const d=Date.now();M(),await D(d)}else _(o.error||"Login failed")}catch(o){_("Login failed. Please try again."),console.error(o)}}async function Ye(){try{X(),V(),statusRefreshHandle&&(clearInterval(statusRefreshHandle),statusRefreshHandle.value=null),await ue(),currentUser=null,Fe(),A()}catch(e){console.error("Logout failed:",e)}}function A(){document.getElementById("login-container").style.display="flex",document.getElementById("dashboard-container").style.display="none",Ze()}function ee(){document.getElementById("login-container").style.display="none",document.getElementById("dashboard-container").style.display="block",document.getElementById("currentUser").textContent=r.currentUser.name||"-";const e=document.getElementById("status-panel");e.style.display="none";const t=document.getElementById("webhooks-section");t&&(t.style.display="none"),document.getElementById("admin-controls").style.display=r.isAdmin?"flex":"none";const n=document.getElementById("history-days");n&&(n.value=r.historyDays),Re()}function _(e){const t=document.getElementById("login-error");t.textContent=e,t.style.display="block"}function Ze(){const e=document.getElementById("login-error");e.style.display="none"}function et(){const e=document.getElementById("error-message");e.style.display="none"}function te(){const e=document.getElementById("loading");e.style.display="block"}function tt(){const e=document.getElementById("loading");e.style.display="none"}function nt(){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.style.display=t.style.display==="block"?"none":"block"}),n.addEventListener("click",()=>{t.style.display="none"}),document.addEventListener("click",s=>{!t.contains(s.target)&&s.target!==e&&(t.style.display="none")}),document.addEventListener("downloadClientsUpdated",z),z())}function z(){const e=document.getElementById("download-client-filter-list");e&&(e.innerHTML="",r.downloadClients.forEach((t,n)=>{const s=document.createElement("div");s.className="filter-item",s.dataset.index=n;const o=document.createElement("input");o.type="checkbox",o.id=`client-${n}`,o.checked=r.selectedDownloadClients.includes(n),o.addEventListener("change",()=>st(n));const d=document.createElement("label");d.htmlFor=`client-${n}`,d.textContent=t.name||`${t.type} (${t.id})`,s.appendChild(o),s.appendChild(d),e.appendChild(s)}),ne())}function st(e){const t=r.selectedDownloadClients.indexOf(e);t>-1?r.selectedDownloadClients.splice(t,1):r.selectedDownloadClients.push(e),De(r.selectedDownloadClients),ne(),K()}function ne(){const e=document.getElementById("download-client-filter-count");e&&(r.selectedDownloadClients.length===0?e.textContent="All":e.textContent=r.selectedDownloadClients.length)}(function(){const t=Q();t&&document.documentElement.setAttribute("data-theme",t)})();function at(){const e=document.getElementById("theme-toggle");e&&e.addEventListener("click",()=>{const n=Q()==="dark"?"light":"dark";ot(n)})}function ot(e){document.documentElement.setAttribute("data-theme",e),Ae(e)}function rt(){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=Me();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("downloads-section"),o=document.getElementById("history-section");e==="downloads"?(t.classList.add("active"),n.classList.remove("active"),s.style.display="block",o.style.display="none",O("downloads")):e==="history"&&(n.classList.add("active"),t.classList.remove("active"),o.style.display="block",s.style.display="none",O("history"),N())}function lt(){T("downloads")}document.addEventListener("DOMContentLoaded",()=>{const e=document.getElementById("login-form");e&&e.addEventListener("submit",Ve);const t=document.getElementById("logout-btn");t&&t.addEventListener("click",Ye);const n=document.getElementById("show-all-toggle");n&&n.addEventListener("change",d=>Ne(d.target.checked));const s=document.getElementById("status-toggle");s&&s.addEventListener("click",_e);const o=document.getElementById("home-btn");o&&o.addEventListener("click",lt),at(),rt(),nt(),$e(),Ue(),pe().then(d=>{const u=document.getElementById("app-version");u&&d&&(u.textContent="v"+d)}),Qe()});