const express = require('express'); const router = express.Router(); const { mapTorrentToDownload } = require('../utils/qbittorrent'); const cache = require('../utils/cache'); const { pollAllServices, POLLING_ENABLED } = require('../utils/poller'); 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; } // Helper function to extract user tag from series/movie // For Radarr: tags is array of IDs, tagMap is id -> label mapping // For Sonarr: tags is array of objects with label property function extractUserTag(tags, tagMap) { if (!tags || tags.length === 0) return null; // If tagMap provided (Radarr), look up label by ID if (tagMap) { for (const tagId of tags) { const label = tagMap.get(tagId); if (label) return label; } return null; } // Sonarr style - tags are objects with label const userTag = tags.find(tag => tag && tag.label); return userTag ? userTag.label : 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}`; } // Get user downloads for authenticated user router.get('/user-downloads', async (req, res) => { try { // Get authenticated user from cookie const userCookie = req.cookies.emby_user; if (!userCookie) { return res.status(401).json({ error: 'Not authenticated' }); } const user = JSON.parse(userCookie); 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}`); // 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 sonarrSeriesData = cache.get('poll:sonarr-series') || []; const sonarrQueueData = cache.get('poll:sonarr-queue') || { records: [] }; const sonarrHistoryData = cache.get('poll:sonarr-history') || { records: [] }; const radarrMoviesData = cache.get('poll:radarr-movies') || []; 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 sonarrSeries = { data: sonarrSeriesData }; const radarrQueue = { data: radarrQueueData }; const radarrHistory = { data: radarrHistoryData }; const radarrMovies = { data: radarrMoviesData }; const radarrTags = { data: radarrTagsData }; console.log(`[Dashboard] Cache data - Series: ${sonarrSeries.data.length}, Movies: ${radarrMovies.data.length}, qBit: ${qbittorrentTorrents.length}`); // Create maps for quick lookup const seriesMap = new Map(sonarrSeries.data.map(s => [s.id, s])); const moviesMap = new Map(radarrMovies.data.map(m => [m.id, m])); // 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])); console.log(`[Dashboard] Radarr tags:`, JSON.stringify(radarrTags.data)); console.log(`[Dashboard] Movies map keys:`, Array.from(moviesMap.keys()).slice(0, 10)); console.log(`[Dashboard] Looking for movieId: 2962`); console.log(`[Dashboard] Movie 2962:`, JSON.stringify(moviesMap.get(2962))); console.log(`[Dashboard] Sample movie structure:`, JSON.stringify(radarrMovies.data[0])); // 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 userTag = extractUserTag(series.tags, sonarrTagMap); if (userTag && (showAll || tagMatchesUser(userTag, username))) { 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, userTag: userTag }; 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 userTag = extractUserTag(movie.tags, radarrTagMap); if (userTag && (showAll || tagMatchesUser(userTag, username))) { 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, userTag: userTag }; 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 userTag = extractUserTag(series.tags, sonarrTagMap); if (userTag && (showAll || tagMatchesUser(userTag, username))) { const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slot.status, size: slot.size, completedAt: slot.completed_time, seriesName: series.title, episodeInfo: sonarrMatch, userTag: userTag }; 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 userTag = extractUserTag(movie.tags, radarrTagMap); if (userTag && (showAll || tagMatchesUser(userTag, username))) { const dlObj = { type: 'movie', title: nzbName, coverArt: getCoverArt(movie), status: slot.status, size: slot.size, completedAt: slot.completed_time, movieName: movie.title, movieInfo: radarrMatch, userTag: userTag }; 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 const userMovies = radarrMovies.data.filter(m => { const tag = extractUserTag(m.tags, radarrTagMap); return tag && tagMatchesUser(tag, username); }); const userSeries = sonarrSeries.data.filter(s => { const tag = extractUserTag(s.tags, sonarrTagMap); return tag && tagMatchesUser(tag, 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 userTag = extractUserTag(series.tags, sonarrTagMap); if (userTag && (showAll || tagMatchesUser(userTag, username))) { 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.userTag = userTag; 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 userTag = extractUserTag(movie.tags, radarrTagMap); if (userTag && (showAll || tagMatchesUser(userTag, username))) { 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.userTag = userTag; 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 userTag = extractUserTag(series.tags, sonarrTagMap); if (userTag && (showAll || tagMatchesUser(userTag, username))) { 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.userTag = userTag; 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 userTag = extractUserTag(movie.tags, radarrTagMap); if (userTag && (showAll || tagMatchesUser(userTag, username))) { 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.userTag = userTag; 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', 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 userTag = extractUserTag(series.tags, sonarrTagMap); if (userTag) { const username = userTag.toLowerCase(); if (userDownloads[username]) { userDownloads[username].seriesCount++; } } }); // Process movie tags allMovies.forEach(movie => { const userTag = extractUserTag(movie.tags, radarrTagMap); if (userTag) { const username = userTag.toLowerCase(); if (userDownloads[username]) { userDownloads[username].movieCount++; } } }); res.json(Object.values(userDownloads)); } catch (error) { res.status(500).json({ error: 'Failed to fetch user summary', details: error.message }); } }); module.exports = router;