// 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 cache = require('../utils/cache'); const { pollAllServices, onPollComplete, offPollComplete, POLLING_ENABLED } = require('../utils/poller'); const downloadClientRegistry = require('../utils/downloadClients'); const sanitizeError = require('../utils/sanitizeError'); const TagMatcher = require('../services/TagMatcher'); const { buildUserDownloads } = require('../services/DownloadBuilder'); const { onHistoryUpdate, offHistoryUpdate } = require('../utils/historyFetcher'); const arrRetrieverRegistry = require('../utils/arrRetrievers'); const { getOmbiInstances } = require('../utils/config'); // Track active SSE clients for disconnect cleanup const activeClients = new Map(); // Helper: read cache snapshot for download building function readCacheSnapshot() { 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') || []; return { sabnzbdQueue: { data: { queue: sabQueueData } }, sabnzbdHistory: { data: { history: sabHistoryData } }, sonarrQueue: { data: sonarrQueueData }, sonarrHistory: { data: sonarrHistoryData }, radarrQueue: { data: radarrQueueData }, radarrHistory: { data: radarrHistoryData }, radarrTags: { data: radarrTagsData }, qbittorrentTorrents, sonarrTagsResults }; } // Helper: build series/movie maps from cache snapshot function buildMetadataMaps(snapshot) { const seriesMap = new Map(); for (const r of snapshot.sonarrQueue.data.records) { if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series); } for (const r of snapshot.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 snapshot.radarrQueue.data.records) { if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie); } for (const r of snapshot.radarrHistory.data.records) { if (r.movie && r.movieId && !moviesMap.has(r.movieId)) moviesMap.set(r.movieId, r.movie); } const sonarrTagMap = new Map(snapshot.sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label])); const radarrTagMap = new Map(snapshot.radarrTags.data.map(t => [t.id, t.label])); return { seriesMap, moviesMap, sonarrTagMap, radarrTagMap }; } /** * @openapi * /api/dashboard/user-downloads: * get: * tags: [Dashboard] * summary: Get user downloads (deprecated) * description: | * **DEPRECATED:** Use GET /api/dashboard/stream for real-time updates via Server-Sent Events. * * Returns current download data for the authenticated user. This endpoint fetches * data from cache or triggers a fresh poll if polling is disabled and cache is empty. * * **Authentication:** Requires valid `emby_user` cookie. * * **Filtering:** * - Non-admin users: Only see downloads tagged with their username * - Admin users: Can see all downloads by setting query parameter `showAll=true` * * **Data Sources:** Aggregates data from SABnzbd, qBittorrent, Transmission, rTorrent, * Sonarr, and Radarr, matching downloads to series/movie metadata. * * **x-integration-notes:** This endpoint returns a snapshot. For real-time updates, * use the SSE stream at /api/dashboard/stream instead. * security: * - CookieAuth: [] * parameters: * - name: showAll * in: query * schema: * type: boolean * default: false * description: 'Admin-only: show all users'' downloads' * responses: * '200': * description: User downloads * content: * application/json: * schema: * type: object * properties: * user: * type: string * example: "john" * isAdmin: * type: boolean * example: false * downloads: * type: array * items: * $ref: '#/components/schemas/NormalizedDownload' * example: * user: "john" * isAdmin: false * downloads: * - id: "abc123" * title: "Show.Name.S01E01.1080p.WEB-DL" * type: "torrent" * client: "qbittorrent" * status: "Downloading" * progress: 45.5 * size: 1073741824 * downloaded: 536870912 * '401': * description: Not authenticated * content: * application/json: * schema: * $ref: '#/components/schemas/ErrorResponse' * '500': * description: Server error * content: * application/json: * schema: * $ref: '#/components/schemas/ErrorResponse' * x-code-samples: * - lang: curl * label: cURL * source: | * curl -X GET "http://localhost:3001/api/dashboard/user-downloads" \ * -b cookies.txt * - lang: JavaScript * label: JavaScript (fetch) * source: | * const response = await fetch('http://localhost:3001/api/dashboard/user-downloads', { * method: 'GET', * credentials: 'include' * }); * const data = await response.json(); */ router.get('/user-downloads', requireAuth, async (req, res) => { try { const user = req.user; const username = user.name.toLowerCase(); const isAdmin = !!user.isAdmin; const showAll = isAdmin && req.query.showAll === 'true'; // When polling is disabled, fetch on-demand if cache has expired if (!POLLING_ENABLED && !cache.get('poll:sab-queue')) { await pollAllServices(); } const snapshot = readCacheSnapshot(); const { seriesMap, moviesMap, sonarrTagMap, radarrTagMap } = buildMetadataMaps(snapshot); const embyUserMap = showAll ? await TagMatcher.getEmbyUsers() : new Map(); // Get Ombi configuration const ombiInstances = getOmbiInstances(); const ombiRetriever = ombiInstances.length > 0 ? arrRetrieverRegistry.getOmbiRetrievers()[0] : null; const ombiBaseUrl = ombiInstances.length > 0 ? ombiInstances[0].url : null; const userDownloads = await buildUserDownloads(snapshot, { username, usernameSanitized: user.name, isAdmin, showAll, seriesMap, moviesMap, sonarrTagMap, radarrTagMap, embyUserMap, ombiRetriever, ombiBaseUrl }); res.json({ user: user.name, isAdmin, downloads: userDownloads }); } catch (error) { console.error(`[Dashboard] Error fetching user downloads:`, error.message); res.status(500).json({ error: 'Failed to fetch user downloads', details: sanitizeError(error) }); } }); /** * @openapi * /api/dashboard/cover-art: * get: * tags: [Dashboard] * summary: Cover art proxy * description: | * Proxies external poster images server-side so the browser loads them from 'self' * and the Content Security Policy (CSP) img-src directive stays tight. * * **Authentication:** Requires valid `emby_user` cookie. * * **Purpose:** Sonarr/Radarr return image URLs from external domains. To maintain * a strict CSP (img-src: 'self'), this endpoint fetches the image server-side and * serves it as if it originated from the sofarr domain. * * **Constraints:** * - URL must be http:// or https:// * - Content type must be an image (image/*) * - Maximum image size: 5 MB * - Timeout: 8 seconds * - Browser cache: 24 hours (public, max-age=86400) * * **Error Responses:** * - 400: Missing URL, invalid URL, or non-image content type * - 502: Failed to fetch from remote server * security: * - CookieAuth: [] * parameters: * - name: url * in: query * required: true * schema: * type: string * format: uri * description: External image URL to proxy * example: "http://sonarr:8989/media/poster.jpg" * responses: * '200': * description: Image data * content: * image/*: * headers: * Content-Type: * description: Image content type from remote server * schema: * type: string * Cache-Control: * description: Cache directive (24 hours) * schema: * type: string * example: "public, max-age=86400" * '400': * description: Invalid URL or non-image * content: * application/json: * schema: * $ref: '#/components/schemas/ErrorResponse' * examples: * missingUrl: * error: "Missing url parameter" * invalidUrl: * error: "Invalid url" * notImage: * error: "Remote URL is not an image" * '502': * description: Failed to fetch from remote * content: * application/json: * schema: * $ref: '#/components/schemas/ErrorResponse' * example: * error: "Failed to fetch cover art" * x-code-samples: * - lang: curl * label: cURL * source: | * curl -X GET "http://localhost:3001/api/dashboard/cover-art?url=http://sonarr:8989/media/poster.jpg" \ * -b cookies.txt \ * --output poster.jpg * - lang: HTML * label: HTML img tag * source: | * Poster */ 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' }); } }); /** * @openapi * /api/dashboard/stream: * get: * tags: [Dashboard] * summary: SSE stream for real-time updates * description: | * Server-Sent Events (SSE) stream that pushes download data to the client on every poll cycle. * Uses the browser's built-in EventSource API (no library required). * * **Authentication:** Requires valid `emby_user` cookie. CSRF token NOT required (GET request). * * **SSE Event Format:** * - Initial payload sent immediately on connection * - Subsequent payloads sent after each poll cycle (or webhook-triggered refresh) * - Each payload is a `data:` frame containing JSON with `user`, `isAdmin`, `downloads`, and `downloadClients` * - Heartbeat comment (`: heartbeat`) sent every 25 seconds to keep connection alive * - Optional `history-update` event when history is refreshed * * **Payload Structure:** * ```json * { * "user": "john", * "isAdmin": false, * "downloads": [...], * "downloadClients": [ * { "id": "qbittorrent-main", "name": "Main qBittorrent", "type": "qbittorrent" } * ] * } * ``` * * **Filtering:** * - Non-admin users: Only see downloads tagged with their username * - Admin users: Can see all downloads by setting query parameter `showAll=true` * * **Connection Management:** * - Server tracks active clients for cleanup and admin status panel * - On client disconnect: deregisters callback, stops heartbeat, removes from active clients * - Browser's EventSource API handles automatic reconnection on network interruption * * **Headers:** * - Content-Type: text/event-stream * - Cache-Control: no-cache, no-transform * - Connection: keep-alive * - X-Accel-Buffering: no (disables nginx proxy buffering) * * **x-integration-notes:** Use EventSource in browser: * ```javascript * const eventSource = new EventSource('/api/dashboard/stream', { withCredentials: true }); * eventSource.onmessage = (event) => { * const data = JSON.parse(event.data); * console.log('Downloads:', data.downloads); * }; * ``` * * **x-integration-notes:** This endpoint uses Server-Sent Events (SSE) for real-time updates. No CSRF token required since it's a GET request. * security: * - CookieAuth: [] * parameters: * - name: showAll * in: query * schema: * type: boolean * default: false * description: 'Admin-only: show all users'' downloads' * responses: * '200': * description: SSE stream established * content: * text/event-stream: * schema: * type: string * description: Server-Sent Events stream * headers: * Content-Type: * schema: * type: string * example: "text/event-stream" * Cache-Control: * schema: * type: string * example: "no-cache, no-transform" * Connection: * schema: * type: string * example: "keep-alive" * X-Accel-Buffering: * schema: * type: string * example: "no" * '401': * description: Not authenticated * content: * application/json: * schema: * $ref: '#/components/schemas/ErrorResponse' * x-code-samples: * - lang: JavaScript * label: Browser EventSource * source: | * const eventSource = new EventSource('/api/dashboard/stream', { * withCredentials: true * }); * eventSource.onmessage = (event) => { * const data = JSON.parse(event.data); * console.log('User:', data.user); * console.log('Downloads:', data.downloads.length); * }; * eventSource.addEventListener('history-update', (event) => { * const data = JSON.parse(event.data); * console.log('History updated for:', data.type); * }); * - lang: curl * label: cURL (test SSE) * source: | * curl -N -H "Cookie: emby_user=..." http://localhost:3001/api/dashboard/stream */ router.get('/stream', requireAuth, async (req, res) => { const user = req.user; const username = user.name.toLowerCase(); const showAll = !!user.isAdmin && req.query.showAll === 'true'; const isAdmin = !!user.isAdmin; // 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() }); 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(); } const snapshot = readCacheSnapshot(); const { seriesMap, moviesMap, sonarrTagMap, radarrTagMap } = buildMetadataMaps(snapshot); const embyUserMap = showAll ? await TagMatcher.getEmbyUsers() : new Map(); // Get Ombi configuration const ombiInstances = getOmbiInstances(); const ombiRetriever = ombiInstances.length > 0 ? arrRetrieverRegistry.getOmbiRetrievers()[0] : null; const ombiBaseUrl = ombiInstances.length > 0 ? ombiInstances[0].url : null; const userDownloads = await buildUserDownloads(snapshot, { username, usernameSanitized: user.name, isAdmin, showAll, seriesMap, moviesMap, sonarrTagMap, radarrTagMap, embyUserMap, ombiRetriever, ombiBaseUrl }); console.log(`[SSE] userDownloads type: ${typeof userDownloads}, isArray: ${Array.isArray(userDownloads)}, value:`, userDownloads); console.log(`[SSE] Sending ${userDownloads?.length || 0} downloads for ${user.name}`); 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); // Subscribe to history update notifications function sendHistoryUpdate(type) { try { res.write(`event: history-update\ndata: ${JSON.stringify({ type })}\n\n`); console.log(`[SSE] Sent history update for ${type} to ${user.name}`); } catch (err) { console.error('[SSE] Error sending history update:', err.message); } } onHistoryUpdate(sendHistoryUpdate); // 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); offHistoryUpdate(sendHistoryUpdate); activeClients.delete(username); console.log(`[SSE] Client disconnected: ${user.name}`); }); }); /** * @openapi * /api/dashboard/blocklist-search: * post: * tags: [Dashboard] * summary: Blocklist and re-search * description: | * Admin-only endpoint that 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. * * **Authentication:** Requires valid `emby_user` cookie (admin only) and `X-CSRF-Token` header. * * **Workflow:** * 1. Validate user is admin * 2. Validate all required fields are present * 3. Delete queue item from Sonarr/Radarr with `removeFromClient=true` and `blocklist=true` * 4. Trigger automatic search command: * - Sonarr: EpisodeSearch with episodeIds * - Radarr: MoviesSearch with movieIds * 5. Invalidate poll cache so next SSE push reflects the removed item * * **Required Fields:** * - `arrQueueId`: Sonarr/Radarr queue record ID * - `arrType`: Must be "sonarr" or "radarr" * - `arrInstanceUrl`: Base URL of the *arr instance * - `arrInstanceKey`: API key for the *arr instance * - `arrContentId`: episodeId (Sonarr) or movieId (Radarr) * - `arrContentType`: Must be "episode" (Sonarr) or "movie" (Radarr) * * **Error Responses:** * - 403: Non-admin user attempts access * - 400: Missing required fields or invalid arrType * - 502: Failed to communicate with *arr instance * * **x-integration-notes:** This endpoint is used from the dashboard UI when an admin * clicks "Blocklist + Re-search" on a failed download. The arr instance credentials * are passed from the download object (which includes them for admin users). * security: * - CookieAuth: [] * - CsrfToken: [] * requestBody: * required: true * content: * application/json: * schema: * $ref: '#/components/schemas/BlocklistSearchRequest' * example: * arrQueueId: 123 * arrType: "sonarr" * arrInstanceUrl: "http://sonarr:8989" * arrInstanceKey: "abc123def456" * arrContentId: 456 * arrContentType: "episode" * responses: * '200': * description: Blocklist and search successful * content: * application/json: * schema: * type: object * properties: * ok: * type: boolean * example: true * example: * ok: true * '400': * description: Missing required fields or invalid arrType * content: * application/json: * schema: * $ref: '#/components/schemas/ErrorResponse' * example: * error: "Missing required fields" * '403': * description: Admin access required * content: * application/json: * schema: * $ref: '#/components/schemas/ErrorResponse' * example: * error: "Admin access required" * '502': * description: Failed to communicate with *arr instance * content: * application/json: * schema: * $ref: '#/components/schemas/ErrorResponse' * example: * error: "Failed to blocklist and search" * x-code-samples: * - lang: JavaScript * label: JavaScript (fetch) * source: | * const csrfResponse = await fetch('http://localhost:3001/api/auth/csrf', { * method: 'GET', * credentials: 'include' * }); * const { csrfToken } = await csrfResponse.json(); * * const response = await fetch('http://localhost:3001/api/dashboard/blocklist-search', { * method: 'POST', * headers: { * 'Content-Type': 'application/json', * 'X-CSRF-Token': csrfToken * }, * credentials: 'include', * body: JSON.stringify({ * arrQueueId: 123, * arrType: 'sonarr', * arrInstanceUrl: 'http://sonarr:8989', * arrInstanceKey: 'abc123def456', * arrContentId: 456, * arrContentType: 'episode' * }) * }); * const data = await response.json(); * console.log(data.ok); // true */ 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) { console.error('[Blocklist] Missing required fields:', { arrQueueId, arrType, arrInstanceUrl, hasKey: !!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;