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
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
771 lines
30 KiB
JavaScript
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;
|