Files
sofarr/server/routes/dashboard.js
T
gronod 7690d959b3
CI / Security audit (push) Successful in 1m52s
Docs Check / Markdown lint (push) Successful in 1m37s
Build and Push Docker Image / build (push) Successful in 2m2s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 2m33s
CI / Swagger Validation & Coverage (push) Successful in 3m17s
Docs Check / Mermaid diagram parse check (push) Successful in 3m31s
CI / Tests & coverage (push) Successful in 4m5s
fix: blocklist-search lookup against queue cache instead of downloadClientRegistry
Fixes the root cause of the regression from v1.7.16. The v1.7.16 fix
correctly cast arrQueueId to String, but the lookup was performed
against downloadClientRegistry.getAllDownloads() which returns raw
download client data (qBittorrent, SABnzbd, etc.) that never has
arrQueueId populated.

The fix now looks up the queue record directly from the Sonarr/Radarr
queue cache where record.id is the numeric queue ID, using String()
casting on both sides to handle the DOM-dataset (string) vs API
response (number) type difference.

Resolves Gitea Issue #48
Closes #48
2026-05-24 22:48:17 +01:00

771 lines
30 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 } = 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) 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: [] };
const showAllOmbi = showAll; // Use the same showAll flag for Ombi
const filteredOmbiMovieRequests = filterRequestsByUser(ombiRequests.movie || [], username, showAllOmbi);
const filteredOmbiTvRequests = filterRequestsByUser(ombiRequests.tv || [], username, showAllOmbi);
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;