Files
sofarr/server/routes/dashboard.js
T
gronod a38fc4a8ce
Build and Push Docker Image / build (push) Successful in 42s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 56s
CI / Security audit (push) Successful in 1m14s
CI / Tests & coverage (push) Successful in 1m32s
refactor: extract status route and WebhookStatus service, slim dashboard.js
- Extract /status route to server/routes/status.js
- Create server/services/WebhookStatus.js with checkWebhookConfigured and aggregateMetrics
- Slim dashboard.js to pure HTTP orchestration (559→283 lines, 49.4% reduction)
- Remove /user-summary and /webhook-metrics routes from dashboard.js
- Mount status router at /api/status in server/index.js and server/app.js
- Update tests to use new /api/status/status endpoint
- Fix test expectation for speed field (number vs string)

All 571 tests passing.
2026-05-20 22:50:40 +01:00

284 lines
11 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');
// 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 };
}
// Get user downloads for authenticated user
// DEPRECATED: Use /stream endpoint for real-time updates
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();
const userDownloads = await buildUserDownloads(snapshot, {
username,
usernameSanitized: user.name,
isAdmin,
showAll,
seriesMap,
moviesMap,
sonarrTagMap,
radarrTagMap,
embyUserMap
});
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) });
}
});
// Cover art proxy — fetches external poster images server-side so the
// browser loads them from 'self' and the CSP img-src stays tight.
// Requires authentication. Only proxies http/https URLs.
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' });
}
});
// SSE stream — pushes download data to the client on every poll cycle.
// Uses the browser's built-in EventSource API (no library required).
// Auth is enforced by requireAuth (emby_user cookie sent with the upgrade request).
// No CSRF token needed — SSE is a GET request (safe method, no state change).
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();
const userDownloads = buildUserDownloads(snapshot, {
username,
usernameSanitized: user.name,
isAdmin,
showAll,
seriesMap,
moviesMap,
sonarrTagMap,
radarrTagMap,
embyUserMap
});
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()
}));
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);
// 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);
activeClients.delete(username);
console.log(`[SSE] Client disconnected: ${user.name}`);
});
});
/**
* POST /api/dashboard/blocklist-search
*
* Admin-only. 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.
*
* Body: {
* arrQueueId: number — Sonarr/Radarr queue record id
* arrType: 'sonarr'|'radarr'
* arrInstanceUrl: string — base URL of the arr instance
* arrInstanceKey: string — API key for the arr instance
* arrContentId: number — episodeId (Sonarr) or movieId (Radarr)
* arrContentType: 'episode'|'movie'
* }
*/
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) {
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;