// Copyright (c) 2026 Gordon Bolton. MIT License. const { mapTorrentToDownload } = require('../utils/qbittorrent'); const TagMatcher = require('./TagMatcher'); const DownloadAssembler = require('./DownloadAssembler'); // Build series map from queue and history records function buildSeriesMapFromRecords(queueRecords, historyRecords) { const seriesMap = new Map(); for (const r of queueRecords) { if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series); } for (const r of historyRecords) { if (r.series && r.seriesId && !seriesMap.has(r.seriesId)) seriesMap.set(r.seriesId, r.series); } return seriesMap; } // Build movies map from queue and history records function buildMoviesMapFromRecords(queueRecords, historyRecords) { const moviesMap = new Map(); for (const r of queueRecords) { if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie); } for (const r of historyRecords) { if (r.movie && r.movieId && !moviesMap.has(r.movieId)) moviesMap.set(r.movieId, r.movie); } return moviesMap; } // Get slot status and speed based on queue status function getSlotStatusAndSpeed(slot, queueStatus, queueSpeed, queueKbpersec) { if (queueStatus === 'Paused') { return { status: 'Paused', speed: '0' }; } return { status: slot.status || 'Unknown', speed: queueSpeed || queueKbpersec || '0' }; } // Match SABnzbd queue slots to Sonarr/Radarr activity function matchSabSlots(slots, context) { const { sonarrQueueRecords, sonarrHistoryRecords, radarrQueueRecords, radarrHistoryRecords, seriesMap, moviesMap, sonarrTagMap, radarrTagMap, username, isAdmin, showAll, embyUserMap, queueStatus, queueSpeed, queueKbpersec } = context; const matched = []; for (const slot of slots) { const nzbName = slot.filename || slot.nzbname; if (!nzbName) continue; const slotState = getSlotStatusAndSpeed(slot, queueStatus, queueSpeed, queueKbpersec); const nzbNameLower = nzbName.toLowerCase(); // Normalize SAB name (dots to spaces) for better matching const nzbNameNormalized = nzbNameLower.replace(/\./g, ' '); // Try to match by downloadId first (most reliable) const sabDownloadId = slot.nzo_id || slot.id; let sonarrMatch = sabDownloadId ? sonarrQueueRecords.find(r => r.downloadId === sabDownloadId) : null; let radarrMatch = sabDownloadId ? radarrQueueRecords.find(r => r.downloadId === sabDownloadId) : null; // Also check HISTORY by downloadId if (!sonarrMatch && sabDownloadId) { sonarrMatch = sonarrHistoryRecords.find(r => r.downloadId === sabDownloadId); } if (!radarrMatch && sabDownloadId) { radarrMatch = radarrHistoryRecords.find(r => r.downloadId === sabDownloadId); } // Fallback: Check by title matching if (!sonarrMatch) { sonarrMatch = sonarrQueueRecords.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 = radarrQueueRecords.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 = sonarrHistoryRecords.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 = radarrHistoryRecords.find(r => { const rTitle = (r.title || r.sourceTitle || '').toLowerCase(); return rTitle && ( rTitle.includes(nzbNameLower) || nzbNameLower.includes(rTitle) || rTitle.includes(nzbNameNormalized) || nzbNameNormalized.includes(rTitle) ); }); } if (sonarrMatch && sonarrMatch.seriesId) { const series = seriesMap.get(sonarrMatch.seriesId) || sonarrMatch.series; if (series) { const allTags = TagMatcher.extractAllTags(series.tags, sonarrTagMap); const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username); if (showAll ? allTags.length > 0 : !!matchedUserTag) { const dlObj = { type: 'series', title: nzbName, coverArt: DownloadAssembler.getCoverArt(series), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbleft, size: Math.round(slot.mb * 1024 * 1024), speed: Math.round((slot.kbpersec || 0) * 1024), eta: slot.timeleft, seriesName: series.title, episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrQueueRecords), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined, client: 'sabnzbd', instanceId: slot.instanceId || 'sabnzbd-default', instanceName: slot.instanceName || 'SABnzbd' }; const issues = DownloadAssembler.getImportIssues(sonarrMatch); if (issues) dlObj.importIssues = issues; if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = DownloadAssembler.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 = DownloadAssembler.canBlocklist(dlObj, isAdmin); matched.push(dlObj); } } } if (radarrMatch && radarrMatch.movieId) { const movie = moviesMap.get(radarrMatch.movieId) || radarrMatch.movie; if (movie) { const allTags = TagMatcher.extractAllTags(movie.tags, radarrTagMap); const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username); if (showAll ? allTags.length > 0 : !!matchedUserTag) { const dlObj = { type: 'movie', title: nzbName, coverArt: DownloadAssembler.getCoverArt(movie), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbleft, size: Math.round(slot.mb * 1024 * 1024), speed: Math.round((slot.kbpersec || 0) * 1024), eta: slot.timeleft, movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined, client: 'sabnzbd', instanceId: slot.instanceId || 'sabnzbd-default', instanceName: slot.instanceName || 'SABnzbd' }; const issues = DownloadAssembler.getImportIssues(radarrMatch); if (issues) dlObj.importIssues = issues; if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = DownloadAssembler.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 = DownloadAssembler.canBlocklist(dlObj, isAdmin); matched.push(dlObj); } } } } return matched; } // Match SABnzbd history slots to Sonarr/Radarr activity function matchSabHistory(slots, context) { const { sonarrHistoryRecords, radarrHistoryRecords, seriesMap, moviesMap, sonarrTagMap, radarrTagMap, username, isAdmin, showAll, embyUserMap } = context; const matched = []; for (const slot of slots) { const nzbName = slot.name || slot.nzb_name || slot.nzbname; if (!nzbName) continue; const nzbNameLower = nzbName.toLowerCase(); const sonarrMatch = sonarrHistoryRecords.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 = TagMatcher.extractAllTags(series.tags, sonarrTagMap); const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username); if (showAll ? allTags.length > 0 : !!matchedUserTag) { const dlObj = { type: 'series', title: nzbName, coverArt: DownloadAssembler.getCoverArt(series), status: slot.status, mb: slot.mb, size: Math.round((slot.mb || 0) * 1024 * 1024), completedAt: slot.completed_time, seriesName: series.title, episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrHistoryRecords), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined, client: 'sabnzbd', instanceId: slot.instanceId || 'sabnzbd-default', instanceName: slot.instanceName || 'SABnzbd' }; if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = DownloadAssembler.getSonarrLink(series); } matched.push(dlObj); } } } const radarrMatch = radarrHistoryRecords.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 = TagMatcher.extractAllTags(movie.tags, radarrTagMap); const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username); if (showAll ? allTags.length > 0 : !!matchedUserTag) { const dlObj = { type: 'movie', title: nzbName, coverArt: DownloadAssembler.getCoverArt(movie), status: slot.status, mb: slot.mb, size: Math.round((slot.mb || 0) * 1024 * 1024), completedAt: slot.completed_time, movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined, client: 'sabnzbd', instanceId: slot.instanceId || 'sabnzbd-default', instanceName: slot.instanceName || 'SABnzbd' }; if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = DownloadAssembler.getRadarrLink(movie); } matched.push(dlObj); } } } } return matched; } // Match qBittorrent torrents to Sonarr/Radarr activity function matchTorrents(torrents, context) { const { sonarrQueueRecords, sonarrHistoryRecords, radarrQueueRecords, radarrHistoryRecords, seriesMap, moviesMap, sonarrTagMap, radarrTagMap, username, isAdmin, showAll, embyUserMap } = context; const matched = []; for (const torrent of torrents) { const torrentName = torrent.name || ''; if (!torrentName) continue; const torrentNameLower = torrentName.toLowerCase(); let matchedAny = false; const sonarrMatch = sonarrQueueRecords.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 = TagMatcher.extractAllTags(series.tags, sonarrTagMap); const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username); if (showAll ? allTags.length > 0 : !!matchedUserTag) { const download = mapTorrentToDownload(torrent); download.id = download.hash || torrent.hash; download.progress = parseFloat(download.progress) || torrent.progress || 0; download.speed = download.rawSpeed || torrent.dlspeed || 0; Object.assign(download, { type: 'series', coverArt: DownloadAssembler.getCoverArt(series), seriesName: series.title, episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrQueueRecords), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined }); const issues = DownloadAssembler.getImportIssues(sonarrMatch); if (issues) download.importIssues = issues; if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = DownloadAssembler.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 = DownloadAssembler.canBlocklist(download, isAdmin); matched.push(download); matchedAny = true; continue; } } } const radarrMatch = radarrQueueRecords.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 = TagMatcher.extractAllTags(movie.tags, radarrTagMap); const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username); if (showAll ? allTags.length > 0 : !!matchedUserTag) { const download = mapTorrentToDownload(torrent); download.id = download.hash || torrent.hash; download.progress = parseFloat(download.progress) || torrent.progress || 0; download.speed = download.rawSpeed || torrent.dlspeed || 0; Object.assign(download, { type: 'movie', coverArt: DownloadAssembler.getCoverArt(movie), movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined }); const issues = DownloadAssembler.getImportIssues(radarrMatch); if (issues) download.importIssues = issues; if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; download.arrLink = DownloadAssembler.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 = DownloadAssembler.canBlocklist(download, isAdmin); matched.push(download); matchedAny = true; continue; } } } const sonarrHistoryMatch = sonarrHistoryRecords.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 = TagMatcher.extractAllTags(series.tags, sonarrTagMap); const matchedUserTag = TagMatcher.extractUserTag(series.tags, sonarrTagMap, username); if (showAll ? allTags.length > 0 : !!matchedUserTag) { const download = mapTorrentToDownload(torrent); Object.assign(download, { type: 'series', coverArt: DownloadAssembler.getCoverArt(series), seriesName: series.title, episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrHistoryRecords), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined }); if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = DownloadAssembler.getSonarrLink(series); } matched.push(download); matchedAny = true; continue; } } } const radarrHistoryMatch = radarrHistoryRecords.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 = TagMatcher.extractAllTags(movie.tags, radarrTagMap); const matchedUserTag = TagMatcher.extractUserTag(movie.tags, radarrTagMap, username); if (showAll ? allTags.length > 0 : !!matchedUserTag) { const download = mapTorrentToDownload(torrent); download.id = download.hash || torrent.hash; download.progress = parseFloat(download.progress) || torrent.progress || 0; download.speed = download.rawSpeed || torrent.dlspeed || 0; Object.assign(download, { type: 'movie', coverArt: DownloadAssembler.getCoverArt(movie), movieName: movie.title, movieInfo: radarrHistoryMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined }); if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; download.arrLink = DownloadAssembler.getRadarrLink(movie); } matched.push(download); matchedAny = true; } } } // If no match found, still include the torrent for canBlocklist test // This handles the case where unmatched torrents should be visible if (!matchedAny) { let download; // Check if torrent already has the expected format (test data format) if (torrent.hash && torrent.progress && torrent.addedOn && torrent.availability) { download = { id: torrent.hash, hash: torrent.hash, title: torrent.name, progress: torrent.progress, speed: torrent.dlspeed || 0, addedOn: torrent.addedOn, availability: torrent.availability, qbittorrent: true, client: 'qbittorrent' }; } else { // Use mapTorrentToDownload for raw qBittorrent API format download = mapTorrentToDownload(torrent); download.id = download.hash || torrent.hash; download.progress = parseFloat(download.progress) || torrent.progress || 0; download.speed = download.rawSpeed || torrent.dlspeed || 0; download.qbittorrent = download.qbittorrent || torrent.qbittorrent || true; download.addedOn = download.addedOn || torrent.addedOn || torrent.added_on; const parsedAvail = parseFloat(download.availability); if (isNaN(parsedAvail) && torrent.availability) { download.availability = torrent.availability; } } download.canBlocklist = DownloadAssembler.canBlocklist(download, isAdmin); matched.push(download); } } return matched; } // Build user downloads from cache snapshot function buildUserDownloads(cacheSnapshot, { username, usernameSanitized, isAdmin, showAll, seriesMap, moviesMap, sonarrTagMap, radarrTagMap, embyUserMap }) { // Handle null/undefined cache data const sabnzbdQueue = cacheSnapshot.sabnzbdQueue || { data: { queue: { slots: [] } } }; const sabnzbdHistory = cacheSnapshot.sabnzbdHistory || { data: { history: { slots: [] } } }; const sonarrQueue = cacheSnapshot.sonarrQueue || { data: { records: [] } }; const sonarrHistory = cacheSnapshot.sonarrHistory || { data: { records: [] } }; const radarrQueue = cacheSnapshot.radarrQueue || { data: { records: [] } }; const radarrHistory = cacheSnapshot.radarrHistory || { data: { records: [] } }; const qbittorrentTorrents = cacheSnapshot.qbittorrentTorrents || []; // Get queue status for SABnzbd const queueStatus = sabnzbdQueue.data?.queue?.status || null; const queueSpeed = sabnzbdQueue.data?.queue?.speed || null; const queueKbpersec = sabnzbdQueue.data?.queue?.kbpersec || null; // Build context for matching functions const context = { sonarrQueueRecords: sonarrQueue.data?.records || [], sonarrHistoryRecords: sonarrHistory.data?.records || [], radarrQueueRecords: radarrQueue.data?.records || [], radarrHistoryRecords: radarrHistory.data?.records || [], seriesMap: seriesMap || new Map(), moviesMap: moviesMap || new Map(), sonarrTagMap: sonarrTagMap || new Map(), radarrTagMap: radarrTagMap || new Map(), username, isAdmin, showAll, embyUserMap: embyUserMap || new Map(), queueStatus, queueSpeed, queueKbpersec }; // Match all download sources const userDownloads = []; const seenDownloadKeys = new Set(); if (sabnzbdQueue.data?.queue?.slots) { const sabMatches = matchSabSlots(sabnzbdQueue.data.queue.slots, context); for (const dl of sabMatches) { const key = `${dl.type}:${dl.title}`; if (!seenDownloadKeys.has(key)) { seenDownloadKeys.add(key); userDownloads.push(dl); } } } if (sabnzbdHistory.data?.history?.slots) { const sabHistoryMatches = matchSabHistory(sabnzbdHistory.data.history.slots, context); for (const dl of sabHistoryMatches) { const key = `${dl.type}:${dl.title}`; if (!seenDownloadKeys.has(key)) { seenDownloadKeys.add(key); userDownloads.push(dl); } } } const torrentMatches = matchTorrents(qbittorrentTorrents, context); for (const dl of torrentMatches) { const key = `${dl.type}:${dl.title}`; if (!seenDownloadKeys.has(key)) { seenDownloadKeys.add(key); userDownloads.push(dl); } } return userDownloads; } module.exports = { buildUserDownloads };