// Copyright (c) 2026 Gordon Bolton. MIT License. const express = require('express'); const router = express.Router(); const requireAuth = require('../middleware/requireAuth'); const axios = require('axios'); const { mapTorrentToDownload } = require('../utils/qbittorrent'); const cache = require('../utils/cache'); const { pollAllServices, getLastPollTimings, onPollComplete, offPollComplete, POLLING_ENABLED } = require('../utils/poller'); const { getSonarrInstances, getRadarrInstances } = require('../utils/config'); const downloadClientRegistry = require('../utils/downloadClients'); const sanitizeError = require('../utils/sanitizeError'); const TagMatcher = require('../services/TagMatcher'); const DownloadAssembler = require('../services/DownloadAssembler'); const { buildUserDownloads } = require('../services/DownloadBuilder'); // Track active dashboard clients. // SSE connections: registered on connect, removed on close — always accurate. // Legacy HTTP poll clients: pruned after CLIENT_STALE_MS of inactivity. const activeClients = new Map(); const CLIENT_STALE_MS = 30000; function getActiveClients() { const now = Date.now(); for (const [key, client] of activeClients.entries()) { if (client.type !== 'sse' && now - client.lastSeen > CLIENT_STALE_MS) { activeClients.delete(key); } } return Array.from(activeClients.values()); } // Get user downloads for authenticated user router.get('/user-downloads', requireAuth, async (req, res) => { try { const user = req.user; const username = user.name.toLowerCase(); const usernameSanitized = Label(user.name); const isAdmin = !!user.isAdmin; const showAll = isAdmin && req.query.showAll === 'true'; console.log(`[Dashboard] Serving downloads for user: ${user.name} (${username}), isAdmin: ${isAdmin}, showAll: ${showAll}`); // Track this client's refresh rate const clientRefreshRate = parseInt(req.query.refreshRate, 10); if (clientRefreshRate > 0) { activeClients.set(username, { user: user.name, refreshRateMs: clientRefreshRate, lastSeen: Date.now() }); } else { // Client has refresh off or didn't send — still mark as seen but with no rate activeClients.set(username, { user: user.name, refreshRateMs: 0, lastSeen: Date.now() }); } // When polling is disabled, fetch on-demand if cache has expired // The fetched data is cached (30s TTL) so subsequent requests from any user reuse it if (!POLLING_ENABLED && !cache.get('poll:sab-queue')) { console.log(`[Dashboard] Cache expired and polling disabled, fetching on-demand...`); await pollAllServices(); } // Read all data from cache const sabQueueData = cache.get('poll:sab-queue') || { slots: [] }; const sabHistoryData = cache.get('poll:sab-history') || { slots: [] }; const sonarrTagsResults = cache.get('poll:sonarr-tags') || []; const sonarrQueueData = cache.get('poll:sonarr-queue') || { records: [] }; const sonarrHistoryData = cache.get('poll:sonarr-history') || { records: [] }; const radarrQueueData = cache.get('poll:radarr-queue') || { records: [] }; const radarrHistoryData = cache.get('poll:radarr-history') || { records: [] }; const radarrTagsData = cache.get('poll:radarr-tags') || []; const qbittorrentTorrents = cache.get('poll:qbittorrent') || []; // Wrap in the structure the rest of the code expects const sabnzbdQueue = { data: { queue: sabQueueData } }; const sabnzbdHistory = { data: { history: sabHistoryData } }; const sonarrQueue = { data: sonarrQueueData }; const sonarrHistory = { data: sonarrHistoryData }; const radarrQueue = { data: radarrQueueData }; const radarrHistory = { data: radarrHistoryData }; const radarrTags = { data: radarrTagsData }; // Build series/movie maps from embedded objects in queue records const seriesMap = new Map(); for (const r of sonarrQueue.data.records) { if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series); } for (const r of sonarrHistory.data.records) { if (r.series && r.seriesId && !seriesMap.has(r.seriesId)) seriesMap.set(r.seriesId, r.series); } const moviesMap = new Map(); for (const r of radarrQueue.data.records) { if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie); } for (const r of radarrHistory.data.records) { if (r.movie && r.movieId && !moviesMap.has(r.movieId)) moviesMap.set(r.movieId, r.movie); } // Create tag maps (id -> label) const sonarrTagMap = new Map(sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label])); const radarrTagMap = new Map(radarrTags.data.map(t => [t.id, t.label])); // When showing all downloads, fetch full Emby user list to classify tags const embyUserMap = showAll ? await TagMatcher.getEmbyUsers() : new Map(); // Build downloads using the centralized DownloadBuilder service const cacheSnapshot = { sabnzbdQueue, sabnzbdHistory, sonarrQueue, sonarrHistory, radarrQueue, radarrHistory, qbittorrentTorrents }; const userDownloads = await buildUserDownloads(cacheSnapshot, { username, usernameSanitized, isAdmin, showAll, seriesMap, moviesMap, sonarrTagMap, radarrTagMap, embyUserMap }); console.log(`[Dashboard] Found ${userDownloads.length} downloads for user ${username}`); console.log(`[Dashboard] Sending ${userDownloads.length} downloads`); res.json({ user: user.name, isAdmin: isAdmin, downloads: userDownloads }); } catch (error) { console.error(`[Dashboard] Error fetching user downloads:`, error.message); console.error(`[Dashboard] Full error:`, error); res.status(500).json({ error: 'Failed to fetch user downloads', details: sanitizeError(error) }); } }); // Get all users with their download counts router.get('/user-summary', requireAuth, async (req, res) => { try { const sonarrInstances = getSonarrInstances(); const radarrInstances = getRadarrInstances(); // Get all Emby users const usersResponse = await axios.get(`${process.env.EMBY_URL}/Users`, { headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY } }); // Get all series, movies, and tags from all instances const [sonarrSeriesResults, sonarrTagsResults, radarrMoviesResults, radarrTagsResults] = await Promise.all([ Promise.all(sonarrInstances.map(inst => axios.get(`${inst.url}/api/v3/series`, { headers: { 'X-Api-Key': inst.apiKey } }).then(r => r.data).catch(() => []) )), Promise.all(sonarrInstances.map(inst => axios.get(`${inst.url}/api/v3/tag`, { headers: { 'X-Api-Key': inst.apiKey } }).then(r => r.data).catch(() => []) )), Promise.all(radarrInstances.map(inst => axios.get(`${inst.url}/api/v3/movie`, { headers: { 'X-Api-Key': inst.apiKey } }).then(r => r.data).catch(() => []) )), Promise.all(radarrInstances.map(inst => axios.get(`${inst.url}/api/v3/tag`, { headers: { 'X-Api-Key': inst.apiKey } }).then(r => r.data).catch(() => []) )) ]); const allSeries = sonarrSeriesResults.flat(); const sonarrTagMap = new Map(sonarrTagsResults.flat().map(t => [t.id, t.label])); const allMovies = radarrMoviesResults.flat(); const radarrTagMap = new Map(radarrTagsResults.flat().map(t => [t.id, t.label])); // Count downloads per user const userDownloads = {}; usersResponse.data.forEach(user => { userDownloads[user.Name.toLowerCase()] = { username: user.Name, seriesCount: 0, movieCount: 0 }; }); // Process series tags allSeries.forEach(series => { const tags = TagMatcher.extractAllTags(series.tags, sonarrTagMap); tags.forEach(userTag => { const uname = userTag.toLowerCase(); if (userDownloads[uname]) userDownloads[uname].seriesCount++; }); }); // Process movie tags allMovies.forEach(movie => { const tags = TagMatcher.extractAllTags(movie.tags, radarrTagMap); tags.forEach(userTag => { const uname = userTag.toLowerCase(); if (userDownloads[uname]) userDownloads[uname].movieCount++; }); }); res.json(Object.values(userDownloads)); } catch (error) { res.status(500).json({ error: 'Failed to fetch user summary', details: sanitizeError(error) }); } }); // Admin-only status page with cache stats router.get('/status', requireAuth, async (req, res) => { try { const user = req.user; if (!user.isAdmin) { return res.status(403).json({ error: 'Admin access required' }); } const cacheStats = cache.getStats(); const uptime = process.uptime(); // Get webhook metrics const { getGlobalWebhookMetrics } = require('../utils/cache'); const webhookMetrics = getGlobalWebhookMetrics(); // Check if Sofarr webhook is configured in Sonarr/Radarr async function checkWebhookConfigured(instance, type) { try { const response = await axios.get(`${instance.url}/api/v3/notification`, { headers: { 'X-Api-Key': instance.apiKey }, timeout: 5000 }); const notifications = response.data || []; return notifications.some(n => n.name === 'Sofarr' && n.implementation === 'Webhook'); } catch (err) { console.log(`[Status] Failed to check ${type} webhook config: ${err.message}`); return false; } } // Check webhook configuration for each service const sonarrInstances = getSonarrInstances(); const radarrInstances = getRadarrInstances(); const sonarrWebhookConfigured = sonarrInstances.length > 0 ? await checkWebhookConfigured(sonarrInstances[0], 'Sonarr') : false; const radarrWebhookConfigured = radarrInstances.length > 0 ? await checkWebhookConfigured(radarrInstances[0], 'Radarr') : false; // Find Sonarr and Radarr metrics from instances const sonarrMetrics = {}; const radarrMetrics = {}; for (const [url, metrics] of Object.entries(webhookMetrics.instances || {})) { if (url.includes('sonarr')) { sonarrMetrics[url] = metrics; } else if (url.includes('radarr')) { radarrMetrics[url] = metrics; } } // Aggregate metrics for each service const aggregateMetrics = (metricsMap, configured) => { const values = Object.values(metricsMap); if (values.length === 0) { // Return default metrics if configured but no events yet return configured ? { enabled: true, eventsReceived: 0, pollsSkipped: 0, lastEvent: null } : null; } return { enabled: true, eventsReceived: values.reduce((sum, m) => sum + (m.eventsReceived || 0), 0), pollsSkipped: values.reduce((sum, m) => sum + (m.pollsSkipped || 0), 0), lastEvent: values.reduce((latest, m) => { return m.lastWebhookTimestamp > latest ? m.lastWebhookTimestamp : latest; }, 0) }; }; res.json({ server: { uptimeSeconds: Math.floor(uptime), nodeVersion: process.version, memoryUsageMB: Math.round(process.memoryUsage().rss / 1024 / 1024 * 10) / 10, heapUsedMB: Math.round(process.memoryUsage().heapUsed / 1024 / 1024 * 10) / 10, heapTotalMB: Math.round(process.memoryUsage().heapTotal / 1024 / 1024 * 10) / 10 }, polling: { enabled: POLLING_ENABLED, intervalMs: POLLING_ENABLED ? require('../utils/poller').POLL_INTERVAL : 0, lastPoll: getLastPollTimings() }, cache: cacheStats, clients: getActiveClients(), webhooks: { sonarr: aggregateMetrics(sonarrMetrics, sonarrWebhookConfigured), radarr: aggregateMetrics(radarrMetrics, radarrWebhookConfigured) } }); } catch (err) { res.status(500).json({ error: 'Failed to get status', details: err.message }); } }); // Webhook metrics — exposes global and per-instance webhook metrics for the // Webhooks Configuration panel. Available to all authenticated users. router.get('/webhook-metrics', requireAuth, (req, res) => { try { const { getGlobalWebhookMetrics } = require('../utils/cache'); res.json(getGlobalWebhookMetrics()); } catch (err) { res.status(500).json({ error: 'Failed to get webhook metrics', details: err.message }); } }); // Cover art proxy — fetches external poster images server-side so the // browser loads them from 'self' and the CSP img-src stays tight. // Requires authentication. Only proxies http/https URLs. router.get('/cover-art', requireAuth, async (req, res) => { const { url } = req.query; if (!url || typeof url !== 'string') { return res.status(400).json({ error: 'Missing url parameter' }); } let parsed; try { parsed = new URL(url); } catch { return res.status(400).json({ error: 'Invalid url' }); } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { return res.status(400).json({ error: 'Only http/https URLs are supported' }); } try { const response = await axios.get(url, { responseType: 'stream', timeout: 8000, maxContentLength: 5 * 1024 * 1024 // 5 MB max }); const contentType = response.headers['content-type'] || 'image/jpeg'; // Only proxy image content types if (!contentType.startsWith('image/')) { return res.status(400).json({ error: 'Remote URL is not an image' }); } res.setHeader('Content-Type', contentType); res.setHeader('Cache-Control', 'public, max-age=86400'); // 24h browser cache res.setHeader('X-Content-Type-Options', 'nosniff'); response.data.pipe(res); } catch (err) { res.status(502).json({ error: 'Failed to fetch cover art' }); } }); // SSE stream — pushes download data to the client on every poll cycle. // Uses the browser's built-in EventSource API (no library required). // Auth is enforced by requireAuth (emby_user cookie sent with the upgrade request). // No CSRF token needed — SSE is a GET request (safe method, no state change). router.get('/stream', requireAuth, async (req, res) => { const user = req.user; const username = user.name.toLowerCase(); const showAll = !!user.isAdmin && req.query.showAll === 'true'; // SSE headers — disable buffering at every layer res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); // nginx: disable proxy buffering res.flushHeaders(); // Register as an active SSE client activeClients.set(username, { user: user.name, type: 'sse', connectedAt: Date.now(), lastSeen: Date.now() }); console.log(`[SSE] Client connected: ${user.name}`); // Helper: build and send the downloads payload for this user async function sendDownloads() { try { // On-demand: trigger a fresh poll if cache is stale and polling is disabled if (!POLLING_ENABLED && !cache.get('poll:sab-queue')) { await pollAllServices(); } console.log(`[SSE] Building downloads for ${user.name} (showAll=${showAll})`); const isAdmin = !!user.isAdmin; const usernameSanitized = Label(user.name); // Read all data from cache const sabQueueData = cache.get('poll:sab-queue') || { slots: [] }; const sabHistoryData = cache.get('poll:sab-history') || { slots: [] }; const sonarrTagsResults = cache.get('poll:sonarr-tags') || []; const sonarrQueueData = cache.get('poll:sonarr-queue') || { records: [] }; const sonarrHistoryData = cache.get('poll:sonarr-history') || { records: [] }; const radarrQueueData = cache.get('poll:radarr-queue') || { records: [] }; const radarrHistoryData = cache.get('poll:radarr-history') || { records: [] }; const radarrTagsData = cache.get('poll:radarr-tags') || []; const qbittorrentTorrents = cache.get('poll:qbittorrent') || []; // Wrap in the structure the rest of the code expects const sabnzbdQueue = { data: { queue: sabQueueData } }; const sabnzbdHistory = { data: { history: sabHistoryData } }; const sonarrQueue = { data: sonarrQueueData }; const sonarrHistory = { data: sonarrHistoryData }; const radarrQueue = { data: radarrQueueData }; const radarrHistory = { data: radarrHistoryData }; const radarrTags = { data: radarrTagsData }; // Build series/movie maps from embedded objects in queue records const seriesMap = new Map(); for (const r of sonarrQueue.data.records) { if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series); } for (const r of sonarrHistory.data.records) { if (r.series && r.seriesId && !seriesMap.has(r.seriesId)) seriesMap.set(r.seriesId, r.series); } const moviesMap = new Map(); for (const r of radarrQueue.data.records) { if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie); } for (const r of radarrHistory.data.records) { if (r.movie && r.movieId && !moviesMap.has(r.movieId)) moviesMap.set(r.movieId, r.movie); } // Create tag maps (id -> label) const sonarrTagMap = new Map(sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label])); const radarrTagMap = new Map(radarrTags.data.map(t => [t.id, t.label])); const embyUserMap = showAll ? await TagMatcher.getEmbyUsers() : new Map(); // Build downloads using the centralized DownloadBuilder service const cacheSnapshot = { sabnzbdQueue, sabnzbdHistory, sonarrQueue, sonarrHistory, radarrQueue, radarrHistory, qbittorrentTorrents }; const userDownloads = buildUserDownloads(cacheSnapshot, { username, usernameSanitized, isAdmin, showAll, seriesMap, moviesMap, sonarrTagMap, radarrTagMap, embyUserMap }); console.log(`[SSE] Sending ${userDownloads.length} downloads for ${user.name}`); if (userDownloads.length > 0) { console.log(`[SSE] Download titles: ${userDownloads.map(d => d.title).join(', ')}`); } // Get download clients list for ordering/filtering const downloadClients = downloadClientRegistry.getAllClients().map(c => ({ id: c.getInstanceId(), name: c.name, type: c.getClientType() })); res.write(`data: ${JSON.stringify({ user: user.name, isAdmin, downloads: userDownloads, downloadClients })}\n\n`); } catch (err) { console.error('[SSE] Error building payload:', sanitizeError(err)); } } // Send initial data immediately await sendDownloads(); // Subscribe to poll-complete notifications onPollComplete(sendDownloads); // 25s heartbeat comment to keep the connection alive through proxies/load-balancers const heartbeat = setInterval(() => { try { res.write(': heartbeat\n\n'); } catch { /* ignore — cleanup below handles it */ } }, 25000); // Cleanup on client disconnect req.on('close', () => { clearInterval(heartbeat); offPollComplete(sendDownloads); activeClients.delete(username); console.log(`[SSE] Client disconnected: ${user.name}`); }); }); /** * POST /api/dashboard/blocklist-search * * Admin-only. Removes a queue item from Sonarr/Radarr with blocklist=true * (so the release is not grabbed again), then immediately triggers a new * automatic search for the same episode/movie. * * Body: { * arrQueueId: number — Sonarr/Radarr queue record id * arrType: 'sonarr'|'radarr' * arrInstanceUrl: string — base URL of the arr instance * arrInstanceKey: string — API key for the arr instance * arrContentId: number — episodeId (Sonarr) or movieId (Radarr) * arrContentType: 'episode'|'movie' * } */ router.post('/blocklist-search', requireAuth, async (req, res) => { try { const user = req.user; if (!user.isAdmin) { return res.status(403).json({ error: 'Admin access required' }); } const { arrQueueId, arrType, arrInstanceUrl, arrInstanceKey, arrContentId, arrContentType } = req.body; if (!arrQueueId || !arrType || !arrInstanceUrl || !arrInstanceKey || !arrContentId || !arrContentType) { return res.status(400).json({ error: 'Missing required fields' }); } if (arrType !== 'sonarr' && arrType !== 'radarr') { return res.status(400).json({ error: 'arrType must be sonarr or radarr' }); } const headers = { 'X-Api-Key': arrInstanceKey }; // Step 1: Remove from queue with blocklist=true await axios.delete(`${arrInstanceUrl}/api/v3/queue/${arrQueueId}`, { headers, params: { removeFromClient: true, blocklist: true } }); // Step 2: Trigger a new automatic search let commandBody; if (arrType === 'sonarr' && arrContentType === 'episode') { commandBody = { name: 'EpisodeSearch', episodeIds: [arrContentId] }; } else if (arrType === 'radarr' && arrContentType === 'movie') { commandBody = { name: 'MoviesSearch', movieIds: [arrContentId] }; } if (commandBody) { await axios.post(`${arrInstanceUrl}/api/v3/command`, commandBody, { headers }); } // Invalidate the poll cache so the next SSE push reflects the removed item const { pollAllServices } = require('../utils/poller'); pollAllServices().catch(() => {}); console.log(`[Dashboard] Blocklist+search: ${arrType} queueId=${arrQueueId} contentId=${arrContentId} by ${user.name}`); res.json({ ok: true }); } catch (err) { console.error('[Dashboard] blocklist-search error:', sanitizeError(err)); res.status(502).json({ error: 'Failed to blocklist and search', details: sanitizeError(err) }); } }); module.exports = router;