4d860dc787
Build and Push Docker Image / build (push) Successful in 31s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 1m19s
CI / Security audit (push) Successful in 1m34s
CI / Swagger Validation & Coverage (push) Successful in 1m40s
CI / Tests & coverage (push) Successful in 1m51s
701 lines
26 KiB
JavaScript
701 lines
26 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');
|
|
|
|
|
|
// Track active SSE clients for disconnect cleanup
|
|
const activeClients = new Map();
|
|
|
|
// Helper: read cache snapshot for download building
|
|
function readCacheSnapshot() {
|
|
const sabQueueData = cache.get('poll:sab-queue') || { slots: [] };
|
|
const sabHistoryData = cache.get('poll:sab-history') || { slots: [] };
|
|
const sonarrTagsResults = cache.get('poll:sonarr-tags') || [];
|
|
const sonarrQueueData = cache.get('poll:sonarr-queue') || { records: [] };
|
|
const sonarrHistoryData = cache.get('poll:sonarr-history') || { records: [] };
|
|
const radarrQueueData = cache.get('poll:radarr-queue') || { records: [] };
|
|
const radarrHistoryData = cache.get('poll:radarr-history') || { records: [] };
|
|
const radarrTagsData = cache.get('poll:radarr-tags') || [];
|
|
const qbittorrentTorrents = cache.get('poll:qbittorrent') || [];
|
|
|
|
return {
|
|
sabnzbdQueue: { data: { queue: sabQueueData } },
|
|
sabnzbdHistory: { data: { history: sabHistoryData } },
|
|
sonarrQueue: { data: sonarrQueueData },
|
|
sonarrHistory: { data: sonarrHistoryData },
|
|
radarrQueue: { data: radarrQueueData },
|
|
radarrHistory: { data: radarrHistoryData },
|
|
radarrTags: { data: radarrTagsData },
|
|
qbittorrentTorrents,
|
|
sonarrTagsResults
|
|
};
|
|
}
|
|
|
|
// Helper: build series/movie maps from cache snapshot
|
|
function buildMetadataMaps(snapshot) {
|
|
const seriesMap = new Map();
|
|
for (const r of snapshot.sonarrQueue.data.records) {
|
|
if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series);
|
|
}
|
|
for (const r of snapshot.sonarrHistory.data.records) {
|
|
if (r.series && r.seriesId && !seriesMap.has(r.seriesId)) seriesMap.set(r.seriesId, r.series);
|
|
}
|
|
const moviesMap = new Map();
|
|
for (const r of snapshot.radarrQueue.data.records) {
|
|
if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie);
|
|
}
|
|
for (const r of snapshot.radarrHistory.data.records) {
|
|
if (r.movie && r.movieId && !moviesMap.has(r.movieId)) moviesMap.set(r.movieId, r.movie);
|
|
}
|
|
const sonarrTagMap = new Map(snapshot.sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label]));
|
|
const radarrTagMap = new Map(snapshot.radarrTags.data.map(t => [t.id, t.label]));
|
|
return { seriesMap, moviesMap, sonarrTagMap, radarrTagMap };
|
|
}
|
|
|
|
/**
|
|
* @openapi
|
|
* /api/dashboard/user-downloads:
|
|
* get:
|
|
* tags: [Dashboard]
|
|
* summary: Get user downloads (deprecated)
|
|
* description: |
|
|
* **DEPRECATED:** Use GET /api/dashboard/stream for real-time updates via Server-Sent Events.
|
|
*
|
|
* Returns current download data for the authenticated user. This endpoint fetches
|
|
* data from cache or triggers a fresh poll if polling is disabled and cache is empty.
|
|
*
|
|
* **Authentication:** Requires valid `emby_user` cookie.
|
|
*
|
|
* **Filtering:**
|
|
* - Non-admin users: Only see downloads tagged with their username
|
|
* - Admin users: Can see all downloads by setting query parameter `showAll=true`
|
|
*
|
|
* **Data Sources:** Aggregates data from SABnzbd, qBittorrent, Transmission, rTorrent,
|
|
* Sonarr, and Radarr, matching downloads to series/movie metadata.
|
|
*
|
|
* **x-integration-notes:** This endpoint returns a snapshot. For real-time updates,
|
|
* use the SSE stream at /api/dashboard/stream instead.
|
|
* security:
|
|
* - CookieAuth: []
|
|
* parameters:
|
|
* - name: showAll
|
|
* in: query
|
|
* schema:
|
|
* type: boolean
|
|
* default: false
|
|
* description: 'Admin-only: show all users'' downloads'
|
|
* responses:
|
|
* '200':
|
|
* description: User downloads
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* user:
|
|
* type: string
|
|
* example: "john"
|
|
* isAdmin:
|
|
* type: boolean
|
|
* example: false
|
|
* downloads:
|
|
* type: array
|
|
* items:
|
|
* $ref: '#/components/schemas/NormalizedDownload'
|
|
* example:
|
|
* user: "john"
|
|
* isAdmin: false
|
|
* downloads:
|
|
* - id: "abc123"
|
|
* title: "Show.Name.S01E01.1080p.WEB-DL"
|
|
* type: "torrent"
|
|
* client: "qbittorrent"
|
|
* status: "Downloading"
|
|
* progress: 45.5
|
|
* size: 1073741824
|
|
* downloaded: 536870912
|
|
* '401':
|
|
* description: Not authenticated
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/ErrorResponse'
|
|
* '500':
|
|
* description: Server error
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/ErrorResponse'
|
|
* x-code-samples:
|
|
* - lang: curl
|
|
* label: cURL
|
|
* source: |
|
|
* curl -X GET "http://localhost:3001/api/dashboard/user-downloads" \
|
|
* -b cookies.txt
|
|
* - lang: JavaScript
|
|
* label: JavaScript (fetch)
|
|
* source: |
|
|
* const response = await fetch('http://localhost:3001/api/dashboard/user-downloads', {
|
|
* method: 'GET',
|
|
* credentials: 'include'
|
|
* });
|
|
* const data = await response.json();
|
|
*/
|
|
router.get('/user-downloads', requireAuth, async (req, res) => {
|
|
try {
|
|
const user = req.user;
|
|
const username = user.name.toLowerCase();
|
|
const isAdmin = !!user.isAdmin;
|
|
const showAll = isAdmin && req.query.showAll === 'true';
|
|
|
|
// When polling is disabled, fetch on-demand if cache has expired
|
|
if (!POLLING_ENABLED && !cache.get('poll:sab-queue')) {
|
|
await pollAllServices();
|
|
}
|
|
|
|
const snapshot = readCacheSnapshot();
|
|
const { seriesMap, moviesMap, sonarrTagMap, radarrTagMap } = buildMetadataMaps(snapshot);
|
|
const embyUserMap = showAll ? await TagMatcher.getEmbyUsers() : new Map();
|
|
|
|
// Get Ombi configuration
|
|
const ombiInstances = getOmbiInstances();
|
|
const ombiRetriever = ombiInstances.length > 0 ? arrRetrieverRegistry.getOmbiRetrievers()[0] : null;
|
|
const ombiBaseUrl = ombiInstances.length > 0 ? ombiInstances[0].url : null;
|
|
|
|
const userDownloads = await buildUserDownloads(snapshot, {
|
|
username,
|
|
usernameSanitized: user.name,
|
|
isAdmin,
|
|
showAll,
|
|
seriesMap,
|
|
moviesMap,
|
|
sonarrTagMap,
|
|
radarrTagMap,
|
|
embyUserMap,
|
|
ombiRetriever,
|
|
ombiBaseUrl
|
|
});
|
|
|
|
res.json({
|
|
user: user.name,
|
|
isAdmin,
|
|
downloads: userDownloads
|
|
});
|
|
} catch (error) {
|
|
console.error(`[Dashboard] Error fetching user downloads:`, error.message);
|
|
res.status(500).json({ error: 'Failed to fetch user downloads', details: sanitizeError(error) });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /api/dashboard/cover-art:
|
|
* get:
|
|
* tags: [Dashboard]
|
|
* summary: Cover art proxy
|
|
* description: |
|
|
* Proxies external poster images server-side so the browser loads them from 'self'
|
|
* and the Content Security Policy (CSP) img-src directive stays tight.
|
|
*
|
|
* **Authentication:** Requires valid `emby_user` cookie.
|
|
*
|
|
* **Purpose:** Sonarr/Radarr return image URLs from external domains. To maintain
|
|
* a strict CSP (img-src: 'self'), this endpoint fetches the image server-side and
|
|
* serves it as if it originated from the sofarr domain.
|
|
*
|
|
* **Constraints:**
|
|
* - URL must be http:// or https://
|
|
* - Content type must be an image (image/*)
|
|
* - Maximum image size: 5 MB
|
|
* - Timeout: 8 seconds
|
|
* - Browser cache: 24 hours (public, max-age=86400)
|
|
*
|
|
* **Error Responses:**
|
|
* - 400: Missing URL, invalid URL, or non-image content type
|
|
* - 502: Failed to fetch from remote server
|
|
* security:
|
|
* - CookieAuth: []
|
|
* parameters:
|
|
* - name: url
|
|
* in: query
|
|
* required: true
|
|
* schema:
|
|
* type: string
|
|
* format: uri
|
|
* description: External image URL to proxy
|
|
* example: "http://sonarr:8989/media/poster.jpg"
|
|
* responses:
|
|
* '200':
|
|
* description: Image data
|
|
* content:
|
|
* image/*:
|
|
* headers:
|
|
* Content-Type:
|
|
* description: Image content type from remote server
|
|
* schema:
|
|
* type: string
|
|
* Cache-Control:
|
|
* description: Cache directive (24 hours)
|
|
* schema:
|
|
* type: string
|
|
* example: "public, max-age=86400"
|
|
* '400':
|
|
* description: Invalid URL or non-image
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/ErrorResponse'
|
|
* examples:
|
|
* missingUrl:
|
|
* error: "Missing url parameter"
|
|
* invalidUrl:
|
|
* error: "Invalid url"
|
|
* notImage:
|
|
* error: "Remote URL is not an image"
|
|
* '502':
|
|
* description: Failed to fetch from remote
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/ErrorResponse'
|
|
* example:
|
|
* error: "Failed to fetch cover art"
|
|
* x-code-samples:
|
|
* - lang: curl
|
|
* label: cURL
|
|
* source: |
|
|
* curl -X GET "http://localhost:3001/api/dashboard/cover-art?url=http://sonarr:8989/media/poster.jpg" \
|
|
* -b cookies.txt \
|
|
* --output poster.jpg
|
|
* - lang: HTML
|
|
* label: HTML img tag
|
|
* source: |
|
|
* <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] userDownloads type: ${typeof userDownloads}, isArray: ${Array.isArray(userDownloads)}, value:`, userDownloads);
|
|
console.log(`[SSE] Sending ${userDownloads?.length || 0} downloads for ${user.name}`);
|
|
const downloadClients = downloadClientRegistry.getAllClients().map(c => ({
|
|
id: c.getInstanceId(),
|
|
name: c.name,
|
|
type: c.getClientType()
|
|
}));
|
|
res.write(`data: ${JSON.stringify({ user: user.name, isAdmin, downloads: userDownloads, downloadClients })}\n\n`);
|
|
} catch (err) {
|
|
console.error('[SSE] Error building payload:', sanitizeError(err));
|
|
}
|
|
}
|
|
|
|
// Send initial data immediately
|
|
await sendDownloads();
|
|
|
|
// Subscribe to poll-complete notifications
|
|
onPollComplete(sendDownloads);
|
|
|
|
// Subscribe to history update notifications
|
|
function sendHistoryUpdate(type) {
|
|
try {
|
|
res.write(`event: history-update\ndata: ${JSON.stringify({ type })}\n\n`);
|
|
console.log(`[SSE] Sent history update for ${type} to ${user.name}`);
|
|
} catch (err) {
|
|
console.error('[SSE] Error sending history update:', err.message);
|
|
}
|
|
}
|
|
onHistoryUpdate(sendHistoryUpdate);
|
|
|
|
// 25s heartbeat comment to keep the connection alive through proxies/load-balancers
|
|
const heartbeat = setInterval(() => {
|
|
try { res.write(': heartbeat\n\n'); } catch { /* ignore — cleanup below handles it */ }
|
|
}, 25000);
|
|
|
|
// Cleanup on client disconnect
|
|
req.on('close', () => {
|
|
clearInterval(heartbeat);
|
|
offPollComplete(sendDownloads);
|
|
offHistoryUpdate(sendHistoryUpdate);
|
|
activeClients.delete(username);
|
|
console.log(`[SSE] Client disconnected: ${user.name}`);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* @openapi
|
|
* /api/dashboard/blocklist-search:
|
|
* post:
|
|
* tags: [Dashboard]
|
|
* summary: Blocklist and re-search
|
|
* description: |
|
|
* Admin-only endpoint that removes a queue item from Sonarr/Radarr with blocklist=true
|
|
* (so the release is not grabbed again), then immediately triggers a new automatic search
|
|
* for the same episode/movie.
|
|
*
|
|
* **Authentication:** Requires valid `emby_user` cookie (admin only) and `X-CSRF-Token` header.
|
|
*
|
|
* **Workflow:**
|
|
* 1. Validate user is admin
|
|
* 2. Validate all required fields are present
|
|
* 3. Delete queue item from Sonarr/Radarr with `removeFromClient=true` and `blocklist=true`
|
|
* 4. Trigger automatic search command:
|
|
* - Sonarr: EpisodeSearch with episodeIds
|
|
* - Radarr: MoviesSearch with movieIds
|
|
* 5. Invalidate poll cache so next SSE push reflects the removed item
|
|
*
|
|
* **Required Fields:**
|
|
* - `arrQueueId`: Sonarr/Radarr queue record ID
|
|
* - `arrType`: Must be "sonarr" or "radarr"
|
|
* - `arrInstanceUrl`: Base URL of the *arr instance
|
|
* - `arrInstanceKey`: API key for the *arr instance
|
|
* - `arrContentId`: episodeId (Sonarr) or movieId (Radarr)
|
|
* - `arrContentType`: Must be "episode" (Sonarr) or "movie" (Radarr)
|
|
*
|
|
* **Error Responses:**
|
|
* - 403: Non-admin user attempts access
|
|
* - 400: Missing required fields or invalid arrType
|
|
* - 502: Failed to communicate with *arr instance
|
|
*
|
|
* **x-integration-notes:** This endpoint is used from the dashboard UI when an admin
|
|
* clicks "Blocklist + Re-search" on a failed download. The arr instance credentials
|
|
* are passed from the download object (which includes them for admin users).
|
|
* security:
|
|
* - CookieAuth: []
|
|
* - CsrfToken: []
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/BlocklistSearchRequest'
|
|
* example:
|
|
* arrQueueId: 123
|
|
* arrType: "sonarr"
|
|
* arrInstanceUrl: "http://sonarr:8989"
|
|
* arrInstanceKey: "abc123def456"
|
|
* arrContentId: 456
|
|
* arrContentType: "episode"
|
|
* responses:
|
|
* '200':
|
|
* description: Blocklist and search successful
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* ok:
|
|
* type: boolean
|
|
* example: true
|
|
* example:
|
|
* ok: true
|
|
* '400':
|
|
* description: Missing required fields or invalid arrType
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/ErrorResponse'
|
|
* example:
|
|
* error: "Missing required fields"
|
|
* '403':
|
|
* description: Admin access required
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/ErrorResponse'
|
|
* example:
|
|
* error: "Admin access required"
|
|
* '502':
|
|
* description: Failed to communicate with *arr instance
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* $ref: '#/components/schemas/ErrorResponse'
|
|
* example:
|
|
* error: "Failed to blocklist and search"
|
|
* x-code-samples:
|
|
* - lang: JavaScript
|
|
* label: JavaScript (fetch)
|
|
* source: |
|
|
* const csrfResponse = await fetch('http://localhost:3001/api/auth/csrf', {
|
|
* method: 'GET',
|
|
* credentials: 'include'
|
|
* });
|
|
* const { csrfToken } = await csrfResponse.json();
|
|
*
|
|
* const response = await fetch('http://localhost:3001/api/dashboard/blocklist-search', {
|
|
* method: 'POST',
|
|
* headers: {
|
|
* 'Content-Type': 'application/json',
|
|
* 'X-CSRF-Token': csrfToken
|
|
* },
|
|
* credentials: 'include',
|
|
* body: JSON.stringify({
|
|
* arrQueueId: 123,
|
|
* arrType: 'sonarr',
|
|
* arrInstanceUrl: 'http://sonarr:8989',
|
|
* arrInstanceKey: 'abc123def456',
|
|
* arrContentId: 456,
|
|
* arrContentType: 'episode'
|
|
* })
|
|
* });
|
|
* const data = await response.json();
|
|
* console.log(data.ok); // true
|
|
*/
|
|
router.post('/blocklist-search', requireAuth, async (req, res) => {
|
|
try {
|
|
const user = req.user;
|
|
if (!user.isAdmin) {
|
|
return res.status(403).json({ error: 'Admin access required' });
|
|
}
|
|
|
|
const { arrQueueId, arrType, arrInstanceUrl, arrInstanceKey, arrContentId, arrContentType } = req.body;
|
|
|
|
if (!arrQueueId || !arrType || !arrInstanceUrl || !arrInstanceKey || !arrContentId || !arrContentType) {
|
|
console.error('[Blocklist] Missing required fields:', { arrQueueId, arrType, arrInstanceUrl, hasKey: !!arrInstanceKey, arrContentId, arrContentType });
|
|
return res.status(400).json({ error: 'Missing required fields' });
|
|
}
|
|
if (arrType !== 'sonarr' && arrType !== 'radarr') {
|
|
return res.status(400).json({ error: 'arrType must be sonarr or radarr' });
|
|
}
|
|
|
|
const headers = { 'X-Api-Key': arrInstanceKey };
|
|
|
|
// Step 1: Remove from queue with blocklist=true
|
|
await axios.delete(`${arrInstanceUrl}/api/v3/queue/${arrQueueId}`, {
|
|
headers,
|
|
params: { removeFromClient: true, blocklist: true }
|
|
});
|
|
|
|
// Step 2: Trigger a new automatic search
|
|
let commandBody;
|
|
if (arrType === 'sonarr' && arrContentType === 'episode') {
|
|
commandBody = { name: 'EpisodeSearch', episodeIds: [arrContentId] };
|
|
} else if (arrType === 'radarr' && arrContentType === 'movie') {
|
|
commandBody = { name: 'MoviesSearch', movieIds: [arrContentId] };
|
|
}
|
|
|
|
if (commandBody) {
|
|
await axios.post(`${arrInstanceUrl}/api/v3/command`, commandBody, { headers });
|
|
}
|
|
|
|
// Invalidate the poll cache so the next SSE push reflects the removed item
|
|
const { pollAllServices } = require('../utils/poller');
|
|
pollAllServices().catch(() => {});
|
|
|
|
console.log(`[Dashboard] Blocklist+search: ${arrType} queueId=${arrQueueId} contentId=${arrContentId} by ${user.name}`);
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
console.error('[Dashboard] blocklist-search error:', sanitizeError(err));
|
|
res.status(502).json({ error: 'Failed to blocklist and search', details: sanitizeError(err) });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|