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 (
-
-
-
-
-
-
-
-
-
- {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.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)}
-
-
-
- )}
-
-
- )}
-
-
-
-
- );
-}
-
-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
+
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)
+
+ | Key | Items | Size | TTL |
+ `;
+
+ 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 += `${escapeHtml(e.key)} | ${items} | ${sizeStr} | ${ttlStr} |
`;
+ }
+
+ html += `
`;
+ // 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=`
`}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)
| Key | Items | Size | TTL |
- `;
-
- 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 += `${escapeHtml(e.key)} | ${items} | ${sizeStr} | ${ttlStr} |
`;
- }
-
- html += `
`;
- // 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()});