Files
sofarr/server/routes/dashboard.js
T

811 lines
31 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, getSonarrInstances, getRadarrInstances } = require('../utils/config');
const { extractRequestedUser, filterRequestsByUser, decorateRequestsWithArrLinks, decorateDownloadsWithArrLinks } = require('../utils/ombiHelpers');
const { canBlocklist } = require('../services/DownloadAssembler');
// 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) {
if (!r.series._instanceUrl && r._instanceUrl) {
r.series._instanceUrl = r._instanceUrl;
}
seriesMap.set(r.seriesId, r.series);
}
}
for (const r of snapshot.sonarrHistory.data.records) {
if (r.series && r.seriesId) {
if (!r.series._instanceUrl && r._instanceUrl) {
r.series._instanceUrl = r._instanceUrl;
}
const existing = seriesMap.get(r.seriesId);
if (!existing || (!existing._instanceUrl && r.series._instanceUrl)) {
seriesMap.set(r.seriesId, r.series);
}
}
}
const moviesMap = new Map();
for (const r of snapshot.radarrQueue.data.records) {
if (r.movie && r.movieId) {
if (!r.movie._instanceUrl && r._instanceUrl) {
r.movie._instanceUrl = r._instanceUrl;
}
moviesMap.set(r.movieId, r.movie);
}
}
for (const r of snapshot.radarrHistory.data.records) {
if (r.movie && r.movieId) {
if (!r.movie._instanceUrl && r._instanceUrl) {
r.movie._instanceUrl = r._instanceUrl;
}
const existing = moviesMap.get(r.movieId);
if (!existing || (!existing._instanceUrl && r.movie._instanceUrl)) {
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
});
if (isAdmin) {
await decorateDownloadsWithArrLinks(userDownloads, isAdmin);
}
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
});
if (isAdmin) {
await decorateDownloadsWithArrLinks(userDownloads, isAdmin);
}
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: [] };
const showAllOmbi = showAll; // Use the same showAll flag for Ombi
const filteredOmbiMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAllOmbi).map(r => ({ ...r, mediaType: 'movie' }));
const filteredOmbiTvRequests = filterRequestsByUser(ombiRequests.tv || [], username, showAllOmbi).map(r => ({ ...r, mediaType: 'tv' }));
// Admin only: add Sonarr/Radarr lookup links
if (isAdmin) {
const allFiltered = [...filteredOmbiMovieRequests, ...filteredOmbiTvRequests];
await decorateRequestsWithArrLinks(allFiltered, isAdmin);
}
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();
// For testing purposes, allow closing the stream gracefully after initial payload
if (req.query.testClose === 'true') {
res.end();
return;
}
// 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: |
* 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.
*
* Accessible by admins, or by non-admins who own the item under specific qualifying eligibility conditions:
* - The download has import issues OR
* - The torrent is older than 1 hour and has availability below 100%
*
* **Authentication:** Requires valid `emby_user` cookie and `X-CSRF-Token` header.
*
* **Workflow:**
* 1. Validate user and required fields
* 2. Check blocklist eligibility (admin status or non-admin qualifying criteria)
* 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 (only required for admins; non-admins resolve via server config)
* - `arrContentId`: episodeId (Sonarr) or movieId (Radarr)
* - `arrContentType`: Must be "episode" (Sonarr) or "movie" (Radarr)
*
* **Error Responses:**
* - 403: User lacks permissions (admin or qualifying conditions required)
* - 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 a qualified user or admin
* clicks "Blocklist + Re-search" on a stalled or failed download.
* 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: Permission denied (admin or qualifying conditions required)
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* example:
* error: "Permission denied: admin or qualifying conditions 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;
const { arrQueueId, arrType, arrInstanceUrl, arrInstanceKey, arrContentId, arrContentIds, arrSeriesId, arrContentType } = req.body;
if (!arrQueueId || !arrType || !arrInstanceUrl || !arrContentType) {
console.error('[Blocklist] Missing required fields:', { arrQueueId, arrType, arrInstanceUrl, hasKey: !!arrInstanceKey, 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' });
}
// Look up the queue record directly from the *arr cache.
// downloadClientRegistry.getAllDownloads() returns raw download-client data
// (qBittorrent, SABnzbd, etc.) which never has arrQueueId set — that field
// is only populated later by DownloadMatcher during the SSE build phase.
// Instead, we verify permission by finding the record in the Sonarr/Radarr
// queue cache where record.id is the numeric queue ID.
// Cast both sides to String to handle the DOM dataset → string vs API → number mismatch.
const queueCacheKey = arrType === 'sonarr' ? 'poll:sonarr-queue' : 'poll:radarr-queue';
const queueData = cache.get(queueCacheKey) || { records: [] };
const queueRecord = (queueData.records || []).find(r => r.id != null && String(r.id) === String(arrQueueId));
if (!queueRecord) {
console.error('[Blocklist] Download not found in arr queue cache:', { arrQueueId, arrType });
return res.status(403).json({ error: 'Download not found or permission denied' });
}
// Build a minimal download-like object for canBlocklist eligibility check.
// Includes importIssues so non-admins can blocklist stalled/import-pending items.
const importIssues = require('../services/DownloadAssembler').getImportIssues(queueRecord);
const downloadForCheck = {
importIssues: importIssues || [],
arrQueueId: queueRecord.id,
arrType
};
// Check if user can blocklist this download
if (!canBlocklist(downloadForCheck, user.isAdmin)) {
console.log('[Blocklist] Permission denied:', { user: user.name, isAdmin: user.isAdmin, arrQueueId, arrType });
return res.status(403).json({ error: 'Permission denied: admin or qualifying conditions required' });
}
// Resolve API key: use provided key (admin) or look up from instance config (non-admin)
let apiKey = arrInstanceKey;
if (!apiKey) {
const instances = arrType === 'sonarr' ? getSonarrInstances() : getRadarrInstances();
const instance = instances.find(inst => inst.url === arrInstanceUrl);
if (!instance || !instance.apiKey) {
console.error('[Blocklist] Instance not found or missing API key:', { arrType, arrInstanceUrl });
return res.status(400).json({ error: 'Instance not found or missing API key' });
}
apiKey = instance.apiKey;
}
const headers = { 'X-Api-Key': apiKey };
// 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') {
if (arrContentId) {
commandBody = { name: 'EpisodeSearch', episodeIds: [arrContentId] };
} else if (Array.isArray(arrContentIds) && arrContentIds.length > 0) {
commandBody = { name: 'EpisodeSearch', episodeIds: arrContentIds.map(Number) };
} else if (arrSeriesId) {
commandBody = { name: 'SeriesSearch', seriesId: Number(arrSeriesId) };
}
} else if (arrType === 'radarr' && arrContentType === 'movie' && arrContentId) {
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 || 'none'} 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;