Files
sofarr/server/routes/dashboard.js
Gronod d8584d0511 fix(security #7,#8,#9): signed cookies, isAdmin tamper-proof, schema validation
#7 isAdmin trusted from unsigned cookie:
  - isAdmin is derived server-side from Emby Policy at login time
  - Cookie is now signed (HMAC) when COOKIE_SECRET env var is set;
    Express rejects tampered signatures (signedCookies returns false)
  - dashboard.js /user-downloads and /status now use requireAuth
    middleware (req.user) instead of re-parsing cookie directly

#8 cookie-parser used without signing secret:
  - cookieParser(COOKIE_SECRET) in index.js when env var is set
  - Hard-fails at startup in production if COOKIE_SECRET unset
  - Warns in development

#9 Cookie JSON parsed without schema validation:
  - parseSessionCookie() in auth.js and requireAuth.js both validate:
    id (non-empty string), name (non-empty string), isAdmin (boolean)
  - Invalid/tampered cookies return null / 401 respectively
2026-05-16 16:20:37 +01:00

726 lines
31 KiB
JavaScript

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, POLLING_ENABLED } = require('../utils/poller');
const { getSonarrInstances, getRadarrInstances } = require('../utils/config');
const EMBY_URL = process.env.EMBY_URL;
const EMBY_API_KEY = process.env.EMBY_API_KEY;
// 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}`;
}
// 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(`${EMBY_URL}/Users`, {
headers: { 'X-MediaBrowser-Token': 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: Map<username, { refreshRateMs, lastSeen }>
const activeClients = new Map();
const CLIENT_STALE_MS = 30000; // consider client gone after 30s of no requests
function getActiveClients() {
const now = Date.now();
// Prune stale clients
for (const [key, client] of activeClients.entries()) {
if (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,
episodeInfo: sonarrMatch,
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);
}
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);
}
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,
episodeInfo: sonarrMatch,
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.episodeInfo = sonarrMatch;
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);
}
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);
}
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.episodeInfo = sonarrHistoryMatch;
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: error.message });
}
});
// 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(`${EMBY_URL}/Users`, {
headers: { 'X-MediaBrowser-Token': 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: error.message });
}
});
// Admin-only status page with cache stats
router.get('/status', requireAuth, (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();
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()
});
} catch (err) {
res.status(500).json({ error: 'Failed to get status', details: err.message });
}
});
module.exports = router;