0acd452ebd
- Stage 1: Fetch 100 records immediately for fast display - Stage 2+: Background fetch up to 1000 records in batches of 100 - Date-based cursor pagination to avoid race conditions - Deduplication by record ID to prevent duplicates - SSE push to clients when history cache is updated - Shared background fetch state for concurrent user requests
297 lines
11 KiB
JavaScript
297 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');
|
|
const { onHistoryUpdate, offHistoryUpdate } = require('../utils/historyFetcher');
|
|
|
|
|
|
// 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);
|
|
|
|
// 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}`);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 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;
|