Files
sofarr/server/routes/dashboard.js
T
gronod 9862c0555c
Build and Push Docker Image / build (push) Successful in 47s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 1m24s
CI / Security audit (push) Successful in 1m47s
CI / Swagger Validation & Coverage (push) Successful in 2m11s
CI / Tests & coverage (push) Successful in 2m27s
Handle Ombi API object-format requestedUser field
The Ombi API returns requestedUser as an OmbiUser object instead of a string.
Add extractRequestedUser helper to extract username from various fields
(alias, userAlias, userName, normalizedUserName) with fallback to legacy string format.
Update client and server routes to use the helper for consistent username extraction.
2026-05-21 21:56:36 +01:00

728 lines
27 KiB
JavaScript

// 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');
const { extractRequestedUser } = require('../utils/ombiHelpers');
// 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') || [];
const ombiRequests = cache.get('poll:ombi-requests') || { movie: [], tv: [] };
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,
ombiRequests
};
}
// 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: |
* <img src="/api/dashboard/cover-art?url=http://sonarr:8989/media/poster.jpg" alt="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] Sending ${userDownloads.length} downloads for ${user.name}`);
const downloadClients = downloadClientRegistry.getAllClients().map(c => ({
id: c.getInstanceId(),
name: c.name,
type: c.getClientType()
}));
// Filter Ombi requests by user if not admin or if showAll is false
const ombiRequests = snapshot.ombiRequests || { movie: [], tv: [] };
let filteredOmbiMovieRequests = ombiRequests.movie || [];
let filteredOmbiTvRequests = ombiRequests.tv || [];
const showAllOmbi = showAll; // Use the same showAll flag for Ombi
if (!showAllOmbi && username) {
const usernameLower = username.toLowerCase();
filteredOmbiMovieRequests = filteredOmbiMovieRequests.filter(req => {
const requestedUser = extractRequestedUser(req);
return requestedUser.toLowerCase() === usernameLower;
});
filteredOmbiTvRequests = filteredOmbiTvRequests.filter(req => {
const requestedUser = extractRequestedUser(req);
return requestedUser.toLowerCase() === usernameLower;
});
}
const ombiRequestsFiltered = {
movie: filteredOmbiMovieRequests,
tv: filteredOmbiTvRequests
};
res.write(`data: ${JSON.stringify({ user: user.name, isAdmin, downloads: userDownloads, downloadClients, ombiRequests: ombiRequestsFiltered, ombiBaseUrl })}\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;