All checks were successful
SAB items often persist after Sonarr has processed them. Previously only checked the active queue, now also checks history records so completed downloads still appear.
1318 lines
63 KiB
JavaScript
1318 lines
63 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 { mapTorrentToDownload } = require('../utils/qbittorrent');
|
|
const cache = require('../utils/cache');
|
|
const { pollAllServices, getLastPollTimings, onPollComplete, offPollComplete, POLLING_ENABLED } = require('../utils/poller');
|
|
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
|
|
const sanitizeError = require('../utils/sanitizeError');
|
|
|
|
|
|
// Helper function to extract poster/cover art URL from a movie or series object
|
|
function getCoverArt(item) {
|
|
if (!item || !item.images) return null;
|
|
const poster = item.images.find(img => img.coverType === 'poster');
|
|
if (poster) return poster.remoteUrl || poster.url || null;
|
|
// Fallback to fanart if no poster
|
|
const fanart = item.images.find(img => img.coverType === 'fanart');
|
|
return fanart ? (fanart.remoteUrl || fanart.url || null) : null;
|
|
}
|
|
|
|
// Return all resolved tag labels for a series/movie.
|
|
// For Radarr: tags is array of IDs, tagMap is id -> label mapping.
|
|
// For Sonarr: tags are objects with a label property.
|
|
function extractAllTags(tags, tagMap) {
|
|
if (!tags || tags.length === 0) return [];
|
|
if (tagMap) {
|
|
return tags.map(id => tagMap.get(id)).filter(Boolean);
|
|
}
|
|
return tags.map(t => t && t.label).filter(Boolean);
|
|
}
|
|
|
|
// Return the tag label that matches the current username, or null.
|
|
function extractUserTag(tags, tagMap, username) {
|
|
const allLabels = extractAllTags(tags, tagMap);
|
|
if (!allLabels.length) return null;
|
|
if (username) {
|
|
const match = allLabels.find(label => tagMatchesUser(label, username));
|
|
if (match) return match;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Replicate Ombi's StringHelper.SanitizeTagLabel: lowercase, replace non-alphanumeric with hyphen, collapse, trim
|
|
function sanitizeTagLabel(input) {
|
|
if (!input) return '';
|
|
return input.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
|
}
|
|
|
|
// Check if a tag matches the username: exact match first, then sanitized match
|
|
function tagMatchesUser(tag, username) {
|
|
if (!tag || !username) return false;
|
|
const tagLower = tag.toLowerCase();
|
|
// Exact match (handles users whose tags weren't mangled)
|
|
if (tagLower === username) return true;
|
|
// Sanitized match (handles Ombi-mangled tags for email-style usernames)
|
|
if (tagLower === sanitizeTagLabel(username)) return true;
|
|
return false;
|
|
}
|
|
|
|
// Extract import issues from a Sonarr/Radarr queue record
|
|
function getImportIssues(queueRecord) {
|
|
if (!queueRecord) return null;
|
|
const state = queueRecord.trackedDownloadState;
|
|
const status = queueRecord.trackedDownloadStatus;
|
|
if (state !== 'importPending' && status !== 'warning' && status !== 'error') return null;
|
|
const messages = [];
|
|
if (queueRecord.statusMessages && queueRecord.statusMessages.length > 0) {
|
|
for (const sm of queueRecord.statusMessages) {
|
|
if (sm.messages && sm.messages.length > 0) {
|
|
messages.push(...sm.messages);
|
|
} else if (sm.title) {
|
|
messages.push(sm.title);
|
|
}
|
|
}
|
|
}
|
|
if (queueRecord.errorMessage) {
|
|
messages.push(queueRecord.errorMessage);
|
|
}
|
|
if (messages.length === 0) return null;
|
|
return messages;
|
|
}
|
|
|
|
// Helper to build Sonarr web UI link for a series
|
|
function getSonarrLink(series) {
|
|
if (!series || !series._instanceUrl || !series.titleSlug) return null;
|
|
return `${series._instanceUrl}/series/${series.titleSlug}`;
|
|
}
|
|
|
|
// Helper to build Radarr web UI link for a movie
|
|
function getRadarrLink(movie) {
|
|
if (!movie || !movie._instanceUrl || !movie.titleSlug) return null;
|
|
return `${movie._instanceUrl}/movie/${movie.titleSlug}`;
|
|
}
|
|
|
|
// Determine if a download can be blocklisted by the current user
|
|
// Admins: always true (they have arrQueueId)
|
|
// Non-admins: true if importIssues OR (torrent >1h old AND availability<100%)
|
|
function canBlocklist(download, isAdmin) {
|
|
if (isAdmin) return true;
|
|
if (download.importIssues && download.importIssues.length > 0) return true;
|
|
if (download.qbittorrent && download.addedOn && download.availability) {
|
|
const oneHourAgo = Date.now() - 3600000; // 1 hour in ms
|
|
const addedOn = new Date(download.addedOn).getTime();
|
|
const isOldEnough = addedOn < oneHourAgo;
|
|
const availability = parseFloat(download.availability);
|
|
const isLowAvailability = availability < 100;
|
|
return isOldEnough && isLowAvailability;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Extract episode info from a Sonarr queue/history record.
|
|
// Returns { season, episode, title } or null if data is missing.
|
|
function extractEpisode(record) {
|
|
const ep = record.episode || {};
|
|
const s = ep.seasonNumber != null ? ep.seasonNumber : record.seasonNumber;
|
|
const e = ep.episodeNumber != null ? ep.episodeNumber : record.episodeNumber;
|
|
if (s == null || e == null) return null;
|
|
const title = ep.title || null;
|
|
return { season: s, episode: e, title };
|
|
}
|
|
|
|
// Find all episodes associated with a download by matching all queue/history records
|
|
// that share the same title string. Returns sorted array of { season, episode, title }.
|
|
function gatherEpisodes(titleLower, sonarrRecords) {
|
|
const episodes = [];
|
|
const seen = new Set();
|
|
for (const r of sonarrRecords) {
|
|
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
|
if (rTitle && (rTitle.includes(titleLower) || titleLower.includes(rTitle))) {
|
|
const ep = extractEpisode(r);
|
|
if (ep) {
|
|
const key = `${ep.season}x${ep.episode}`;
|
|
if (!seen.has(key)) {
|
|
seen.add(key);
|
|
episodes.push(ep);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
episodes.sort((a, b) => a.season - b.season || a.episode - b.episode);
|
|
return episodes;
|
|
}
|
|
|
|
// Fetch all Emby users and return a Map<lowerName -> displayName> (and sanitized variants).
|
|
// Result is cached for 60s to avoid hammering Emby on every dashboard poll.
|
|
async function getEmbyUsers() {
|
|
const cached = cache.get('emby:users');
|
|
if (cached) return cached;
|
|
try {
|
|
const response = await axios.get(`${process.env.EMBY_URL}/Users`, {
|
|
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
|
|
});
|
|
// Build map: both raw lowercase and sanitized form -> display name
|
|
const map = new Map();
|
|
for (const u of response.data) {
|
|
const name = u.Name || '';
|
|
map.set(name.toLowerCase(), name);
|
|
map.set(sanitizeTagLabel(name), name);
|
|
}
|
|
cache.set('emby:users', map, 60000);
|
|
return map;
|
|
} catch (err) {
|
|
console.error('[Dashboard] Failed to fetch Emby users:', err.message);
|
|
return new Map();
|
|
}
|
|
}
|
|
|
|
// Classify each tag label: matched to a known Emby user, or unmatched.
|
|
// Returns array of { label, matchedUser: string|null }
|
|
function buildTagBadges(allTags, embyUserMap) {
|
|
return allTags.map(label => {
|
|
const lower = label.toLowerCase();
|
|
const sanitized = sanitizeTagLabel(label);
|
|
const displayName = embyUserMap.get(lower) || embyUserMap.get(sanitized) || null;
|
|
return { label, matchedUser: displayName };
|
|
});
|
|
}
|
|
|
|
// Track active dashboard clients.
|
|
// SSE connections: registered on connect, removed on close — always accurate.
|
|
// Legacy HTTP poll clients: pruned after CLIENT_STALE_MS of inactivity.
|
|
const activeClients = new Map();
|
|
const CLIENT_STALE_MS = 30000;
|
|
|
|
function getActiveClients() {
|
|
const now = Date.now();
|
|
for (const [key, client] of activeClients.entries()) {
|
|
if (client.type !== 'sse' && now - client.lastSeen > CLIENT_STALE_MS) {
|
|
activeClients.delete(key);
|
|
}
|
|
}
|
|
return Array.from(activeClients.values());
|
|
}
|
|
|
|
// Get user downloads for authenticated user
|
|
router.get('/user-downloads', requireAuth, async (req, res) => {
|
|
try {
|
|
const user = req.user;
|
|
const username = user.name.toLowerCase();
|
|
const usernameSanitized = sanitizeTagLabel(user.name);
|
|
const isAdmin = !!user.isAdmin;
|
|
const showAll = isAdmin && req.query.showAll === 'true';
|
|
console.log(`[Dashboard] Serving downloads for user: ${user.name} (${username}), isAdmin: ${isAdmin}, showAll: ${showAll}`);
|
|
|
|
// Track this client's refresh rate
|
|
const clientRefreshRate = parseInt(req.query.refreshRate, 10);
|
|
if (clientRefreshRate > 0) {
|
|
activeClients.set(username, { user: user.name, refreshRateMs: clientRefreshRate, lastSeen: Date.now() });
|
|
} else {
|
|
// Client has refresh off or didn't send — still mark as seen but with no rate
|
|
activeClients.set(username, { user: user.name, refreshRateMs: 0, lastSeen: Date.now() });
|
|
}
|
|
|
|
// When polling is disabled, fetch on-demand if cache has expired
|
|
// The fetched data is cached (30s TTL) so subsequent requests from any user reuse it
|
|
if (!POLLING_ENABLED && !cache.get('poll:sab-queue')) {
|
|
console.log(`[Dashboard] Cache expired and polling disabled, fetching on-demand...`);
|
|
await pollAllServices();
|
|
}
|
|
|
|
// Read all data from cache
|
|
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') || [];
|
|
|
|
// Wrap in the structure the rest of the code expects
|
|
const sabnzbdQueue = { data: { queue: sabQueueData } };
|
|
const sabnzbdHistory = { data: { history: sabHistoryData } };
|
|
const sonarrQueue = { data: sonarrQueueData };
|
|
const sonarrHistory = { data: sonarrHistoryData };
|
|
const radarrQueue = { data: radarrQueueData };
|
|
const radarrHistory = { data: radarrHistoryData };
|
|
const radarrTags = { data: radarrTagsData };
|
|
|
|
// Build series/movie maps from embedded objects in queue records
|
|
// (history is fetched without includeSeries/includeMovie for speed;
|
|
// history matches fall back to the queue-built map via seriesId/movieId)
|
|
const seriesMap = new Map();
|
|
for (const r of sonarrQueue.data.records) {
|
|
if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series);
|
|
}
|
|
for (const r of 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 radarrQueue.data.records) {
|
|
if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie);
|
|
}
|
|
for (const r of radarrHistory.data.records) {
|
|
if (r.movie && r.movieId && !moviesMap.has(r.movieId)) moviesMap.set(r.movieId, r.movie);
|
|
}
|
|
|
|
// Create tag maps (id -> label)
|
|
const sonarrTagMap = new Map(sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label]));
|
|
const radarrTagMap = new Map(radarrTags.data.map(t => [t.id, t.label]));
|
|
|
|
// When showing all downloads, fetch full Emby user list to classify tags
|
|
const embyUserMap = showAll ? await getEmbyUsers() : new Map();
|
|
|
|
console.log(`[Dashboard] Cache data - Series: ${seriesMap.size}, Movies: ${moviesMap.size}, qBit: ${qbittorrentTorrents.length}`);
|
|
|
|
// Match SABnzbd downloads to Sonarr/Radarr activity
|
|
const userDownloads = [];
|
|
|
|
// Process SABnzbd queue
|
|
const queueStatus = sabnzbdQueue.data.queue ? sabnzbdQueue.data.queue.status : null;
|
|
const queueSpeed = sabnzbdQueue.data.queue ? sabnzbdQueue.data.queue.speed : null;
|
|
const queueKbpersec = sabnzbdQueue.data.queue ? sabnzbdQueue.data.queue.kbpersec : null;
|
|
console.log(`[Dashboard] Queue status: ${queueStatus}, speed: ${queueSpeed}, kbpersec: ${queueKbpersec}`);
|
|
|
|
// Helper to determine status and speed
|
|
function getSlotStatusAndSpeed(slot) {
|
|
// If whole queue is paused, everything is paused with 0 speed
|
|
if (queueStatus === 'Paused') {
|
|
return { status: 'Paused', speed: '0' };
|
|
}
|
|
// Use slot's actual status and queue speed
|
|
return {
|
|
status: slot.status || 'Unknown',
|
|
speed: queueSpeed || queueKbpersec || '0'
|
|
};
|
|
}
|
|
|
|
if (sabnzbdQueue.data.queue && sabnzbdQueue.data.queue.slots) {
|
|
for (const slot of sabnzbdQueue.data.queue.slots) {
|
|
try {
|
|
const nzbName = slot.filename || slot.nzbname;
|
|
if (!nzbName) {
|
|
console.log(`[Dashboard] Skipping slot with no filename/nzbname`);
|
|
continue;
|
|
}
|
|
const slotState = getSlotStatusAndSpeed(slot);
|
|
console.log(`[Dashboard] Slot ${nzbName}: status=${slotState.status}, speed=${slotState.speed}`);
|
|
const nzbNameLower = nzbName.toLowerCase();
|
|
|
|
// Try to match with Sonarr
|
|
const sonarrMatch = sonarrQueue.data.records.find(r => {
|
|
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
|
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
|
});
|
|
|
|
if (sonarrMatch && sonarrMatch.seriesId) {
|
|
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
|
if (series) {
|
|
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
|
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
|
const hasAnyTag = allTags.length > 0;
|
|
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
|
const dlObj = {
|
|
type: 'series',
|
|
title: nzbName,
|
|
coverArt: getCoverArt(series),
|
|
status: slotState.status,
|
|
progress: slot.percentage,
|
|
mb: slot.mb,
|
|
mbmissing: slot.mbmissing,
|
|
size: slot.size,
|
|
speed: slotState.speed,
|
|
eta: slot.timeleft,
|
|
seriesName: series.title,
|
|
episodes: gatherEpisodes(nzbNameLower, sonarrQueue.data.records),
|
|
allTags,
|
|
matchedUserTag: matchedUserTag || null,
|
|
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
|
};
|
|
const issues = getImportIssues(sonarrMatch);
|
|
if (issues) dlObj.importIssues = issues;
|
|
if (isAdmin) {
|
|
dlObj.downloadPath = slot.storage || null;
|
|
dlObj.targetPath = series.path || null;
|
|
dlObj.arrLink = getSonarrLink(series);
|
|
dlObj.arrQueueId = sonarrMatch.id;
|
|
dlObj.arrType = 'sonarr';
|
|
dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
|
dlObj.arrInstanceKey = sonarrMatch._instanceKey || null;
|
|
dlObj.arrContentId = sonarrMatch.episodeId || null;
|
|
dlObj.arrContentType = 'episode';
|
|
}
|
|
dlObj.canBlocklist = canBlocklist(dlObj, isAdmin);
|
|
userDownloads.push(dlObj);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try to match with Radarr
|
|
const radarrMatch = radarrQueue.data.records.find(r => {
|
|
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
|
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
|
});
|
|
|
|
if (radarrMatch && radarrMatch.movieId) {
|
|
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
|
if (movie) {
|
|
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
|
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
|
const hasAnyTag = allTags.length > 0;
|
|
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
|
const dlObj = {
|
|
type: 'movie',
|
|
title: nzbName,
|
|
coverArt: getCoverArt(movie),
|
|
status: slotState.status,
|
|
progress: slot.percentage,
|
|
mb: slot.mb,
|
|
mbmissing: slot.mbmissing,
|
|
size: slot.size,
|
|
speed: slotState.speed,
|
|
eta: slot.timeleft,
|
|
movieName: movie.title,
|
|
movieInfo: radarrMatch,
|
|
allTags,
|
|
matchedUserTag: matchedUserTag || null,
|
|
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
|
};
|
|
const issues = getImportIssues(radarrMatch);
|
|
if (issues) dlObj.importIssues = issues;
|
|
if (isAdmin) {
|
|
dlObj.downloadPath = slot.storage || null;
|
|
dlObj.targetPath = movie.path || null;
|
|
dlObj.arrLink = getRadarrLink(movie);
|
|
dlObj.arrQueueId = radarrMatch.id;
|
|
dlObj.arrType = 'radarr';
|
|
dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null;
|
|
dlObj.arrInstanceKey = radarrMatch._instanceKey || null;
|
|
dlObj.arrContentId = radarrMatch.movieId || null;
|
|
dlObj.arrContentType = 'movie';
|
|
}
|
|
dlObj.canBlocklist = canBlocklist(dlObj, isAdmin);
|
|
userDownloads.push(dlObj);
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error(`[Dashboard] Error processing slot:`, err.message);
|
|
console.error(`[Dashboard] Slot data:`, JSON.stringify(slot));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Process SABnzbd history
|
|
if (sabnzbdHistory.data.history && sabnzbdHistory.data.history.slots) {
|
|
for (const slot of sabnzbdHistory.data.history.slots) {
|
|
try {
|
|
const nzbName = slot.name || slot.nzb_name || slot.nzbname;
|
|
if (!nzbName) {
|
|
console.log(`[Dashboard] Skipping history slot with no name/nzb_name/nzbname`);
|
|
continue;
|
|
}
|
|
const nzbNameLower = nzbName.toLowerCase();
|
|
|
|
// Try to match with Sonarr history
|
|
const sonarrMatch = sonarrHistory.data.records.find(r => {
|
|
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
|
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
|
});
|
|
|
|
if (sonarrMatch && sonarrMatch.seriesId) {
|
|
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
|
if (series) {
|
|
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
|
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
|
const hasAnyTag = allTags.length > 0;
|
|
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
|
const dlObj = {
|
|
type: 'series',
|
|
title: nzbName,
|
|
coverArt: getCoverArt(series),
|
|
status: slot.status,
|
|
size: slot.size,
|
|
completedAt: slot.completed_time,
|
|
seriesName: series.title,
|
|
episodes: gatherEpisodes(nzbNameLower, sonarrHistory.data.records),
|
|
allTags,
|
|
matchedUserTag: matchedUserTag || null,
|
|
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
|
};
|
|
if (isAdmin) {
|
|
dlObj.downloadPath = slot.storage || null;
|
|
dlObj.targetPath = series.path || null;
|
|
dlObj.arrLink = getSonarrLink(series);
|
|
}
|
|
userDownloads.push(dlObj);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try to match with Radarr history
|
|
const radarrMatch = radarrHistory.data.records.find(r => {
|
|
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
|
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
|
});
|
|
|
|
if (radarrMatch && radarrMatch.movieId) {
|
|
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
|
if (movie) {
|
|
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
|
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
|
const hasAnyTag = allTags.length > 0;
|
|
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
|
const dlObj = {
|
|
type: 'movie',
|
|
title: nzbName,
|
|
coverArt: getCoverArt(movie),
|
|
status: slot.status,
|
|
size: slot.size,
|
|
completedAt: slot.completed_time,
|
|
movieName: movie.title,
|
|
movieInfo: radarrMatch,
|
|
allTags,
|
|
matchedUserTag: matchedUserTag || null,
|
|
tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined
|
|
};
|
|
if (isAdmin) {
|
|
dlObj.downloadPath = slot.storage || null;
|
|
dlObj.targetPath = movie.path || null;
|
|
dlObj.arrLink = getRadarrLink(movie);
|
|
}
|
|
userDownloads.push(dlObj);
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error(`[Dashboard] Error processing history slot:`, err.message);
|
|
console.error(`[Dashboard] History slot data:`, JSON.stringify(slot));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Debug: show what queue records look like and which movies/series are tagged for this user
|
|
console.log(`[Dashboard] Sonarr queue titles:`, sonarrQueue.data.records.map(r => ({ title: r.title, seriesId: r.seriesId })));
|
|
console.log(`[Dashboard] Radarr queue titles:`, radarrQueue.data.records.map(r => ({ title: r.title, movieId: r.movieId })));
|
|
console.log(`[Dashboard] Sonarr history titles:`, sonarrHistory.data.records.slice(0, 10).map(r => ({ title: r.title, seriesId: r.seriesId })));
|
|
console.log(`[Dashboard] Radarr history titles:`, radarrHistory.data.records.slice(0, 10).map(r => ({ title: r.title, movieId: r.movieId })));
|
|
console.log(`[Dashboard] Sonarr tag map:`, Array.from(sonarrTagMap.entries()));
|
|
console.log(`[Dashboard] Radarr tag map:`, Array.from(radarrTagMap.entries()));
|
|
|
|
// Show movies/series tagged for this user (from embedded objects in queue/history)
|
|
const userMovies = Array.from(moviesMap.values()).filter(m => {
|
|
return !!extractUserTag(m.tags, radarrTagMap, username);
|
|
});
|
|
const userSeries = Array.from(seriesMap.values()).filter(s => {
|
|
return !!extractUserTag(s.tags, sonarrTagMap, username);
|
|
});
|
|
console.log(`[Dashboard] Movies tagged for ${username}:`, userMovies.map(m => m.title));
|
|
console.log(`[Dashboard] Series tagged for ${username}:`, userSeries.map(s => s.title));
|
|
|
|
// Process qBittorrent torrents - match to user-tagged Sonarr/Radarr activity
|
|
console.log(`[Dashboard] Processing ${qbittorrentTorrents.length} qBittorrent torrents for user ${username}`);
|
|
for (const torrent of qbittorrentTorrents) {
|
|
try {
|
|
const torrentName = torrent.name || '';
|
|
const torrentNameLower = torrentName.toLowerCase();
|
|
if (!torrentName) continue;
|
|
|
|
console.log(`[Dashboard] Checking torrent "${torrentName}"`);
|
|
|
|
// Try to match with Sonarr queue (user-tagged series)
|
|
const sonarrMatch = sonarrQueue.data.records.find(r => {
|
|
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
|
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
|
});
|
|
|
|
if (sonarrMatch && sonarrMatch.seriesId) {
|
|
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
|
if (series) {
|
|
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
|
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
|
const hasAnyTag = allTags.length > 0;
|
|
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
|
console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr series "${series.title}"`);
|
|
const download = mapTorrentToDownload(torrent);
|
|
download.type = 'series';
|
|
download.coverArt = getCoverArt(series);
|
|
download.seriesName = series.title;
|
|
download.episodes = gatherEpisodes(torrentNameLower, sonarrQueue.data.records);
|
|
download.allTags = allTags;
|
|
download.matchedUserTag = matchedUserTag || null;
|
|
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
|
|
const sonarrIssues = getImportIssues(sonarrMatch);
|
|
if (sonarrIssues) download.importIssues = sonarrIssues;
|
|
if (isAdmin) {
|
|
download.downloadPath = download.savePath || null;
|
|
download.targetPath = series.path || null;
|
|
download.arrLink = getSonarrLink(series);
|
|
download.arrQueueId = sonarrMatch.id;
|
|
download.arrType = 'sonarr';
|
|
download.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
|
download.arrInstanceKey = sonarrMatch._instanceKey || null;
|
|
download.arrContentId = sonarrMatch.episodeId || null;
|
|
download.arrContentType = 'episode';
|
|
}
|
|
download.canBlocklist = canBlocklist(download, isAdmin);
|
|
userDownloads.push(download);
|
|
continue; // Skip to next torrent
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try to match with Radarr queue (user-tagged movies)
|
|
const radarrMatch = radarrQueue.data.records.find(r => {
|
|
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
|
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
|
});
|
|
|
|
if (radarrMatch && radarrMatch.movieId) {
|
|
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
|
if (movie) {
|
|
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
|
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
|
const hasAnyTag = allTags.length > 0;
|
|
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
|
console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr movie "${movie.title}"`);
|
|
const download = mapTorrentToDownload(torrent);
|
|
download.type = 'movie';
|
|
download.coverArt = getCoverArt(movie);
|
|
download.movieName = movie.title;
|
|
download.movieInfo = radarrMatch;
|
|
download.allTags = allTags;
|
|
download.matchedUserTag = matchedUserTag || null;
|
|
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
|
|
const radarrIssues = getImportIssues(radarrMatch);
|
|
if (radarrIssues) download.importIssues = radarrIssues;
|
|
if (isAdmin) {
|
|
download.downloadPath = download.savePath || null;
|
|
download.targetPath = movie.path || null;
|
|
download.arrLink = getRadarrLink(movie);
|
|
download.arrQueueId = radarrMatch.id;
|
|
download.arrType = 'radarr';
|
|
download.arrInstanceUrl = radarrMatch._instanceUrl || null;
|
|
download.arrInstanceKey = radarrMatch._instanceKey || null;
|
|
download.arrContentId = radarrMatch.movieId || null;
|
|
download.arrContentType = 'movie';
|
|
}
|
|
download.canBlocklist = canBlocklist(download, isAdmin);
|
|
userDownloads.push(download);
|
|
continue; // Skip to next torrent
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try to match with Sonarr history
|
|
const sonarrHistoryMatch = sonarrHistory.data.records.find(r => {
|
|
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
|
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
|
});
|
|
|
|
if (sonarrHistoryMatch && sonarrHistoryMatch.seriesId) {
|
|
const series = seriesMap.get(sonarrHistoryMatch.seriesId) || sonarrHistoryMatch.series;
|
|
if (series) {
|
|
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
|
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
|
const hasAnyTag = allTags.length > 0;
|
|
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
|
console.log(`[Dashboard] Matched torrent "${torrentName}" to Sonarr history "${series.title}"`);
|
|
const download = mapTorrentToDownload(torrent);
|
|
download.type = 'series';
|
|
download.coverArt = getCoverArt(series);
|
|
download.seriesName = series.title;
|
|
download.episodes = gatherEpisodes(torrentNameLower, sonarrHistory.data.records);
|
|
download.allTags = allTags;
|
|
download.matchedUserTag = matchedUserTag || null;
|
|
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
|
|
if (isAdmin) {
|
|
download.downloadPath = download.savePath || null;
|
|
download.targetPath = series.path || null;
|
|
download.arrLink = getSonarrLink(series);
|
|
}
|
|
userDownloads.push(download);
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try to match with Radarr history
|
|
const radarrHistoryMatch = radarrHistory.data.records.find(r => {
|
|
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
|
return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle));
|
|
});
|
|
|
|
if (radarrHistoryMatch && radarrHistoryMatch.movieId) {
|
|
const movie = moviesMap.get(radarrHistoryMatch.movieId) || radarrHistoryMatch.movie;
|
|
if (movie) {
|
|
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
|
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
|
const hasAnyTag = allTags.length > 0;
|
|
if (showAll ? hasAnyTag : !!matchedUserTag) {
|
|
console.log(`[Dashboard] Matched torrent "${torrentName}" to Radarr history "${movie.title}"`);
|
|
const download = mapTorrentToDownload(torrent);
|
|
download.type = 'movie';
|
|
download.coverArt = getCoverArt(movie);
|
|
download.movieName = movie.title;
|
|
download.movieInfo = radarrHistoryMatch;
|
|
download.allTags = allTags;
|
|
download.matchedUserTag = matchedUserTag || null;
|
|
download.tagBadges = showAll ? buildTagBadges(allTags, embyUserMap) : undefined;
|
|
if (isAdmin) {
|
|
download.downloadPath = download.savePath || null;
|
|
download.targetPath = movie.path || null;
|
|
download.arrLink = getRadarrLink(movie);
|
|
}
|
|
userDownloads.push(download);
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error(`[Dashboard] Error processing torrent:`, err.message);
|
|
console.error(`[Dashboard] Torrent data:`, JSON.stringify(torrent));
|
|
}
|
|
}
|
|
|
|
console.log(`[Dashboard] Found ${userDownloads.length} downloads for user ${username}`);
|
|
console.log(`[Dashboard] Sending ${userDownloads.length} downloads`);
|
|
if (userDownloads.length > 0) {
|
|
console.log(`[Dashboard] First download:`, JSON.stringify(userDownloads[0]));
|
|
}
|
|
|
|
res.json({
|
|
user: user.name,
|
|
isAdmin: isAdmin,
|
|
downloads: userDownloads
|
|
});
|
|
} catch (error) {
|
|
console.error(`[Dashboard] Error fetching user downloads:`, error.message);
|
|
console.error(`[Dashboard] Full error:`, error);
|
|
res.status(500).json({ error: 'Failed to fetch user downloads', details: sanitizeError(error) });
|
|
}
|
|
});
|
|
|
|
// Get all users with their download counts
|
|
router.get('/user-summary', requireAuth, async (req, res) => {
|
|
try {
|
|
const sonarrInstances = getSonarrInstances();
|
|
const radarrInstances = getRadarrInstances();
|
|
|
|
// Get all Emby users
|
|
const usersResponse = await axios.get(`${process.env.EMBY_URL}/Users`, {
|
|
headers: { 'X-MediaBrowser-Token': process.env.EMBY_API_KEY }
|
|
});
|
|
|
|
// Get all series, movies, and tags from all instances
|
|
const [sonarrSeriesResults, sonarrTagsResults, radarrMoviesResults, radarrTagsResults] = await Promise.all([
|
|
Promise.all(sonarrInstances.map(inst =>
|
|
axios.get(`${inst.url}/api/v3/series`, {
|
|
headers: { 'X-Api-Key': inst.apiKey }
|
|
}).then(r => r.data).catch(() => [])
|
|
)),
|
|
Promise.all(sonarrInstances.map(inst =>
|
|
axios.get(`${inst.url}/api/v3/tag`, {
|
|
headers: { 'X-Api-Key': inst.apiKey }
|
|
}).then(r => r.data).catch(() => [])
|
|
)),
|
|
Promise.all(radarrInstances.map(inst =>
|
|
axios.get(`${inst.url}/api/v3/movie`, {
|
|
headers: { 'X-Api-Key': inst.apiKey }
|
|
}).then(r => r.data).catch(() => [])
|
|
)),
|
|
Promise.all(radarrInstances.map(inst =>
|
|
axios.get(`${inst.url}/api/v3/tag`, {
|
|
headers: { 'X-Api-Key': inst.apiKey }
|
|
}).then(r => r.data).catch(() => [])
|
|
))
|
|
]);
|
|
|
|
const allSeries = sonarrSeriesResults.flat();
|
|
const sonarrTagMap = new Map(sonarrTagsResults.flat().map(t => [t.id, t.label]));
|
|
const allMovies = radarrMoviesResults.flat();
|
|
const radarrTagMap = new Map(radarrTagsResults.flat().map(t => [t.id, t.label]));
|
|
|
|
// Count downloads per user
|
|
const userDownloads = {};
|
|
usersResponse.data.forEach(user => {
|
|
userDownloads[user.Name.toLowerCase()] = {
|
|
username: user.Name,
|
|
seriesCount: 0,
|
|
movieCount: 0
|
|
};
|
|
});
|
|
|
|
// Process series tags
|
|
allSeries.forEach(series => {
|
|
const tags = extractAllTags(series.tags, sonarrTagMap);
|
|
tags.forEach(userTag => {
|
|
const uname = userTag.toLowerCase();
|
|
if (userDownloads[uname]) userDownloads[uname].seriesCount++;
|
|
});
|
|
});
|
|
|
|
// Process movie tags
|
|
allMovies.forEach(movie => {
|
|
const tags = extractAllTags(movie.tags, radarrTagMap);
|
|
tags.forEach(userTag => {
|
|
const uname = userTag.toLowerCase();
|
|
if (userDownloads[uname]) userDownloads[uname].movieCount++;
|
|
});
|
|
});
|
|
|
|
res.json(Object.values(userDownloads));
|
|
} catch (error) {
|
|
res.status(500).json({ error: 'Failed to fetch user summary', details: sanitizeError(error) });
|
|
}
|
|
});
|
|
|
|
// Admin-only status page with cache stats
|
|
router.get('/status', requireAuth, async (req, res) => {
|
|
try {
|
|
const user = req.user;
|
|
if (!user.isAdmin) {
|
|
return res.status(403).json({ error: 'Admin access required' });
|
|
}
|
|
|
|
const cacheStats = cache.getStats();
|
|
const uptime = process.uptime();
|
|
|
|
// Get webhook metrics
|
|
const { getGlobalWebhookMetrics } = require('../utils/cache');
|
|
const webhookMetrics = getGlobalWebhookMetrics();
|
|
|
|
// Check if Sofarr webhook is configured in Sonarr/Radarr
|
|
async function checkWebhookConfigured(instance, type) {
|
|
try {
|
|
const response = await axios.get(`${instance.url}/api/v3/notification`, {
|
|
headers: { 'X-Api-Key': instance.apiKey },
|
|
timeout: 5000
|
|
});
|
|
const notifications = response.data || [];
|
|
return notifications.some(n => n.name === 'Sofarr' && n.implementation === 'Webhook');
|
|
} catch (err) {
|
|
console.log(`[Status] Failed to check ${type} webhook config: ${err.message}`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Check webhook configuration for each service
|
|
const sonarrInstances = getSonarrInstances();
|
|
const radarrInstances = getRadarrInstances();
|
|
|
|
const sonarrWebhookConfigured = sonarrInstances.length > 0
|
|
? await checkWebhookConfigured(sonarrInstances[0], 'Sonarr')
|
|
: false;
|
|
const radarrWebhookConfigured = radarrInstances.length > 0
|
|
? await checkWebhookConfigured(radarrInstances[0], 'Radarr')
|
|
: false;
|
|
|
|
// Find Sonarr and Radarr metrics from instances
|
|
const sonarrMetrics = {};
|
|
const radarrMetrics = {};
|
|
for (const [url, metrics] of Object.entries(webhookMetrics.instances || {})) {
|
|
if (url.includes('sonarr')) {
|
|
sonarrMetrics[url] = metrics;
|
|
} else if (url.includes('radarr')) {
|
|
radarrMetrics[url] = metrics;
|
|
}
|
|
}
|
|
|
|
// Aggregate metrics for each service
|
|
const aggregateMetrics = (metricsMap, configured) => {
|
|
const values = Object.values(metricsMap);
|
|
if (values.length === 0) {
|
|
// Return default metrics if configured but no events yet
|
|
return configured ? {
|
|
enabled: true,
|
|
eventsReceived: 0,
|
|
pollsSkipped: 0,
|
|
lastEvent: null
|
|
} : null;
|
|
}
|
|
return {
|
|
enabled: true,
|
|
eventsReceived: values.reduce((sum, m) => sum + (m.eventsReceived || 0), 0),
|
|
pollsSkipped: values.reduce((sum, m) => sum + (m.pollsSkipped || 0), 0),
|
|
lastEvent: values.reduce((latest, m) => {
|
|
return m.lastWebhookTimestamp > latest ? m.lastWebhookTimestamp : latest;
|
|
}, 0)
|
|
};
|
|
};
|
|
|
|
res.json({
|
|
server: {
|
|
uptimeSeconds: Math.floor(uptime),
|
|
nodeVersion: process.version,
|
|
memoryUsageMB: Math.round(process.memoryUsage().rss / 1024 / 1024 * 10) / 10,
|
|
heapUsedMB: Math.round(process.memoryUsage().heapUsed / 1024 / 1024 * 10) / 10,
|
|
heapTotalMB: Math.round(process.memoryUsage().heapTotal / 1024 / 1024 * 10) / 10
|
|
},
|
|
polling: {
|
|
enabled: POLLING_ENABLED,
|
|
intervalMs: POLLING_ENABLED ? require('../utils/poller').POLL_INTERVAL : 0,
|
|
lastPoll: getLastPollTimings()
|
|
},
|
|
cache: cacheStats,
|
|
clients: getActiveClients(),
|
|
webhooks: {
|
|
sonarr: aggregateMetrics(sonarrMetrics, sonarrWebhookConfigured),
|
|
radarr: aggregateMetrics(radarrMetrics, radarrWebhookConfigured)
|
|
}
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to get status', details: err.message });
|
|
}
|
|
});
|
|
|
|
// Webhook metrics — exposes global and per-instance webhook metrics for the
|
|
// Webhooks Configuration panel. Available to all authenticated users.
|
|
router.get('/webhook-metrics', requireAuth, (req, res) => {
|
|
try {
|
|
const { getGlobalWebhookMetrics } = require('../utils/cache');
|
|
res.json(getGlobalWebhookMetrics());
|
|
} catch (err) {
|
|
res.status(500).json({ error: 'Failed to get webhook metrics', details: err.message });
|
|
}
|
|
});
|
|
|
|
// 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';
|
|
|
|
// 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(), lastSeen: 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();
|
|
}
|
|
|
|
console.log(`[SSE] Building downloads for ${user.name} (showAll=${showAll})`);
|
|
|
|
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') || [];
|
|
|
|
console.log(`[SSE] Data sizes - SAB queue: ${sabQueueData.slots?.length || 0}, SAB history: ${sabHistoryData.slots?.length || 0}, qBit: ${qbittorrentTorrents.length}`);
|
|
console.log(`[SSE] Sonarr queue: ${sonarrQueueData.records?.length || 0}, history: ${sonarrHistoryData.records?.length || 0}`);
|
|
console.log(`[SSE] Radarr queue: ${radarrQueueData.records?.length || 0}, history: ${radarrHistoryData.records?.length || 0}`);
|
|
|
|
const sabnzbdQueue = { data: { queue: sabQueueData } };
|
|
const sabnzbdHistory = { data: { history: sabHistoryData } };
|
|
const sonarrQueue = { data: sonarrQueueData };
|
|
const sonarrHistory = { data: sonarrHistoryData };
|
|
const radarrQueue = { data: radarrQueueData };
|
|
const radarrHistory = { data: radarrHistoryData };
|
|
const radarrTags = { data: radarrTagsData };
|
|
|
|
const seriesMap = new Map();
|
|
for (const r of sonarrQueue.data.records) {
|
|
if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series);
|
|
}
|
|
for (const r of 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 radarrQueue.data.records) {
|
|
if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie);
|
|
}
|
|
for (const r of radarrHistory.data.records) {
|
|
if (r.movie && r.movieId && !moviesMap.has(r.movieId)) moviesMap.set(r.movieId, r.movie);
|
|
}
|
|
|
|
const sonarrTagMap = new Map(sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label]));
|
|
const radarrTagMap = new Map(radarrTags.data.map(t => [t.id, t.label]));
|
|
const embyUserMap = showAll ? await getEmbyUsers() : new Map();
|
|
|
|
// Inline the matching logic (same as /user-downloads)
|
|
const userDownloads = [];
|
|
const isAdmin = !!user.isAdmin;
|
|
const usernameSanitized = sanitizeTagLabel(user.name);
|
|
const queueStatus = sabnzbdQueue.data.queue ? sabnzbdQueue.data.queue.status : null;
|
|
const queueSpeed = sabnzbdQueue.data.queue ? sabnzbdQueue.data.queue.speed : null;
|
|
const queueKbpersec = sabnzbdQueue.data.queue ? sabnzbdQueue.data.queue.kbpersec : null;
|
|
|
|
function getSlotStatusAndSpeed(slot) {
|
|
if (queueStatus === 'Paused') return { status: 'Paused', speed: '0' };
|
|
return { status: slot.status || 'Unknown', speed: queueSpeed || queueKbpersec || '0' };
|
|
}
|
|
|
|
// SABnzbd queue
|
|
let sabSlotsChecked = 0;
|
|
let sabSlotsMatched = 0;
|
|
if (sabnzbdQueue.data.queue && sabnzbdQueue.data.queue.slots) {
|
|
for (const slot of sabnzbdQueue.data.queue.slots) {
|
|
const nzbName = slot.filename || slot.nzbname;
|
|
if (!nzbName) continue;
|
|
sabSlotsChecked++;
|
|
const slotState = getSlotStatusAndSpeed(slot);
|
|
const nzbNameLower = nzbName.toLowerCase();
|
|
|
|
// Normalize SAB name (dots to spaces) for better matching
|
|
const nzbNameNormalized = nzbNameLower.replace(/\./g, ' ');
|
|
|
|
// Check Sonarr/Radarr QUEUE (active downloads)
|
|
let sonarrMatch = sonarrQueue.data.records.find(r => {
|
|
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
|
return rTitle && (
|
|
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
|
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
|
);
|
|
});
|
|
let radarrMatch = radarrQueue.data.records.find(r => {
|
|
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
|
return rTitle && (
|
|
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
|
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
|
);
|
|
});
|
|
|
|
// Also check HISTORY (completed downloads) if no queue match
|
|
if (!sonarrMatch) {
|
|
sonarrMatch = sonarrHistory.data.records.find(r => {
|
|
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
|
return rTitle && (
|
|
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
|
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
|
);
|
|
});
|
|
}
|
|
if (!radarrMatch) {
|
|
radarrMatch = radarrHistory.data.records.find(r => {
|
|
const rTitle = (r.title || r.sourceTitle || '').toLowerCase();
|
|
return rTitle && (
|
|
rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) ||
|
|
rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle)
|
|
);
|
|
});
|
|
}
|
|
// Debug first 5 items - show matches and non-matches
|
|
if (sabSlotsChecked <= 5) {
|
|
if (sonarrMatch) {
|
|
console.log(`[SSE] ✓ Sonarr match: SAB:"${nzbNameLower.substring(0, 50)}" → Sonarr:"${(sonarrMatch.title || sonarrMatch.sourceTitle || '').substring(0, 50)}"`);
|
|
} else if (radarrMatch) {
|
|
console.log(`[SSE] ✓ Radarr match: SAB:"${nzbNameLower.substring(0, 50)}" → Radarr:"${(radarrMatch.title || radarrMatch.sourceTitle || '').substring(0, 50)}"`);
|
|
} else {
|
|
console.log(`[SSE] ✗ No match for SAB: "${nzbNameLower.substring(0, 60)}"`);
|
|
// Show actual Sonarr titles to debug matching
|
|
const sonarrTitles = sonarrQueue.data.records.slice(0, 5).map(r => {
|
|
const fields = {title: r.title, sourceTitle: r.sourceTitle, seriesTitle: r.series?.title, episodeTitle: r.episode?.title};
|
|
return JSON.stringify(fields).substring(0, 80);
|
|
});
|
|
console.log(`[SSE] Sonarr fields: ${sonarrTitles.join(' | ')}`);
|
|
}
|
|
}
|
|
if (sonarrMatch && sonarrMatch.seriesId) {
|
|
sabSlotsMatched++;
|
|
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
|
if (series) {
|
|
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
|
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
|
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
|
const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbmissing, size: slot.size, speed: slotState.speed, eta: slot.timeleft, seriesName: series.title, episodes: gatherEpisodes(nzbNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
|
|
const issues = getImportIssues(sonarrMatch);
|
|
if (issues) dlObj.importIssues = issues;
|
|
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); dlObj.arrQueueId = sonarrMatch.id; dlObj.arrType = 'sonarr'; dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null; dlObj.arrInstanceKey = sonarrMatch._instanceKey || null; dlObj.arrContentId = sonarrMatch.episodeId || null; dlObj.arrContentType = 'episode'; }
|
|
dlObj.canBlocklist = canBlocklist(dlObj, isAdmin);
|
|
userDownloads.push(dlObj);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle Radarr match (radarrMatch already declared above)
|
|
if (radarrMatch && radarrMatch.movieId) {
|
|
sabSlotsMatched++;
|
|
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
|
if (movie) {
|
|
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
|
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
|
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
|
const dlObj = { type: 'movie', title: nzbName, coverArt: getCoverArt(movie), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbmissing, size: slot.size, speed: slotState.speed, eta: slot.timeleft, movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
|
|
const issues = getImportIssues(radarrMatch);
|
|
if (issues) dlObj.importIssues = issues;
|
|
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = getRadarrLink(movie); dlObj.arrQueueId = radarrMatch.id; dlObj.arrType = 'radarr'; dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null; dlObj.arrInstanceKey = radarrMatch._instanceKey || null; dlObj.arrContentId = radarrMatch.movieId || null; dlObj.arrContentType = 'movie'; }
|
|
dlObj.canBlocklist = canBlocklist(dlObj, isAdmin);
|
|
userDownloads.push(dlObj);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// SABnzbd history
|
|
if (sabnzbdHistory.data.history && sabnzbdHistory.data.history.slots) {
|
|
for (const slot of sabnzbdHistory.data.history.slots) {
|
|
const nzbName = slot.name || slot.nzb_name || slot.nzbname;
|
|
if (!nzbName) continue;
|
|
const nzbNameLower = nzbName.toLowerCase();
|
|
|
|
const sonarrMatch = sonarrHistory.data.records.find(r => {
|
|
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
|
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
|
});
|
|
if (sonarrMatch && sonarrMatch.seriesId) {
|
|
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
|
if (series) {
|
|
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
|
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
|
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
|
const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slot.status, size: slot.size, completedAt: slot.completed_time, seriesName: series.title, episodes: gatherEpisodes(nzbNameLower, sonarrHistory.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
|
|
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); }
|
|
userDownloads.push(dlObj);
|
|
}
|
|
}
|
|
}
|
|
|
|
const radarrMatch = radarrHistory.data.records.find(r => {
|
|
const rTitle = (r.sourceTitle || r.title || '').toLowerCase();
|
|
return rTitle && (rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle));
|
|
});
|
|
if (radarrMatch && radarrMatch.movieId) {
|
|
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
|
if (movie) {
|
|
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
|
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
|
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
|
const dlObj = { type: 'movie', title: nzbName, coverArt: getCoverArt(movie), status: slot.status, size: slot.size, completedAt: slot.completed_time, movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined };
|
|
if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = getRadarrLink(movie); }
|
|
userDownloads.push(dlObj);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// qBittorrent
|
|
for (const torrent of qbittorrentTorrents) {
|
|
const torrentName = torrent.name || '';
|
|
if (!torrentName) continue;
|
|
const torrentNameLower = torrentName.toLowerCase();
|
|
|
|
const sonarrMatch = sonarrQueue.data.records.find(r => { const rTitle = (r.title || r.sourceTitle || '').toLowerCase(); return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle)); });
|
|
if (sonarrMatch && sonarrMatch.seriesId) {
|
|
const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series;
|
|
if (series) {
|
|
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
|
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
|
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
|
const download = mapTorrentToDownload(torrent);
|
|
Object.assign(download, { type: 'series', coverArt: getCoverArt(series), seriesName: series.title, episodes: gatherEpisodes(torrentNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined });
|
|
const issues = getImportIssues(sonarrMatch); if (issues) download.importIssues = issues;
|
|
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); download.arrQueueId = sonarrMatch.id; download.arrType = 'sonarr'; download.arrInstanceUrl = sonarrMatch._instanceUrl || null; download.arrInstanceKey = sonarrMatch._instanceKey || null; download.arrContentId = sonarrMatch.episodeId || null; download.arrContentType = 'episode'; }
|
|
download.canBlocklist = canBlocklist(download, isAdmin);
|
|
userDownloads.push(download); continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
const radarrMatch = radarrQueue.data.records.find(r => { const rTitle = (r.title || r.sourceTitle || '').toLowerCase(); return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle)); });
|
|
if (radarrMatch && radarrMatch.movieId) {
|
|
const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie;
|
|
if (movie) {
|
|
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
|
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
|
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
|
const download = mapTorrentToDownload(torrent);
|
|
Object.assign(download, { type: 'movie', coverArt: getCoverArt(movie), movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined });
|
|
const issues = getImportIssues(radarrMatch); if (issues) download.importIssues = issues;
|
|
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; download.arrLink = getRadarrLink(movie); download.arrQueueId = radarrMatch.id; download.arrType = 'radarr'; download.arrInstanceUrl = radarrMatch._instanceUrl || null; download.arrInstanceKey = radarrMatch._instanceKey || null; download.arrContentId = radarrMatch.movieId || null; download.arrContentType = 'movie'; }
|
|
download.canBlocklist = canBlocklist(download, isAdmin);
|
|
userDownloads.push(download); continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
const sonarrHistoryMatch = sonarrHistory.data.records.find(r => { const rTitle = (r.sourceTitle || r.title || '').toLowerCase(); return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle)); });
|
|
if (sonarrHistoryMatch && sonarrHistoryMatch.seriesId) {
|
|
const series = seriesMap.get(sonarrHistoryMatch.seriesId) || sonarrHistoryMatch.series;
|
|
if (series) {
|
|
const allTags = extractAllTags(series.tags, sonarrTagMap);
|
|
const matchedUserTag = extractUserTag(series.tags, sonarrTagMap, username);
|
|
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
|
const download = mapTorrentToDownload(torrent);
|
|
Object.assign(download, { type: 'series', coverArt: getCoverArt(series), seriesName: series.title, episodes: gatherEpisodes(torrentNameLower, sonarrHistory.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined });
|
|
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); }
|
|
userDownloads.push(download); continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
const radarrHistoryMatch = radarrHistory.data.records.find(r => { const rTitle = (r.sourceTitle || r.title || '').toLowerCase(); return rTitle && (rTitle.includes(torrentNameLower) || torrentNameLower.includes(rTitle)); });
|
|
if (radarrHistoryMatch && radarrHistoryMatch.movieId) {
|
|
const movie = moviesMap.get(radarrHistoryMatch.movieId) || radarrHistoryMatch.movie;
|
|
if (movie) {
|
|
const allTags = extractAllTags(movie.tags, radarrTagMap);
|
|
const matchedUserTag = extractUserTag(movie.tags, radarrTagMap, username);
|
|
if (showAll ? allTags.length > 0 : !!matchedUserTag) {
|
|
const download = mapTorrentToDownload(torrent);
|
|
Object.assign(download, { type: 'movie', coverArt: getCoverArt(movie), movieName: movie.title, movieInfo: radarrHistoryMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined });
|
|
if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; download.arrLink = getRadarrLink(movie); }
|
|
userDownloads.push(download);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Write SSE event
|
|
console.log(`[SSE] SAB matching: ${sabSlotsChecked} checked, ${sabSlotsMatched} matched to Sonarr/Radarr`);
|
|
console.log(`[SSE] Sending ${userDownloads.length} downloads for ${user.name}`);
|
|
if (userDownloads.length > 0) {
|
|
console.log(`[SSE] Download titles: ${userDownloads.map(d => d.title).join(', ')}`);
|
|
}
|
|
res.write(`data: ${JSON.stringify({ user: user.name, isAdmin, downloads: userDownloads })}\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;
|