// Copyright (c) 2026 Gordon Bolton. MIT License. /** * DownloadMatcher - Matches download client data to Sonarr/Radarr activity. * Contains logic for matching SABnzbd slots and qBittorrent torrents to media metadata * via download IDs and title matching. */ const { mapTorrentToDownload } = require('../utils/qbittorrent'); const TagMatcher = require('./TagMatcher'); const DownloadAssembler = require('./DownloadAssembler'); /** * Builds a Map of series metadata from Sonarr queue and history records. * @param {Array} queueRecords - Sonarr queue records * @param {Array} historyRecords - Sonarr history records * @returns {Map} Map of seriesId to series object */ 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; } /** * Builds a Map of movie metadata from Radarr queue and history records. * @param {Array} queueRecords - Radarr queue records * @param {Array} historyRecords - Radarr history records * @returns {Map} Map of movieId to movie object */ 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; } /** * Determines the status and speed for a SABnzbd slot based on queue state. * @param {Object} slot - SABnzbd queue slot * @param {string} queueStatus - Overall queue status (e.g., 'Paused') * @param {string} queueSpeed - Queue speed string * @param {string} queueKbpersec - Queue speed in KB/s * @returns {Object} Object with status and speed properties */ function getSlotStatusAndSpeed(slot, queueStatus, queueSpeed, queueKbpersec) { if (queueStatus === 'Paused') { return { status: 'Paused', speed: '0' }; } return { status: slot.status || 'Unknown', speed: queueSpeed || queueKbpersec || '0' }; } /** * Matches SABnzbd queue slots to Sonarr/Radarr activity using download IDs and title matching. * @param {Array} slots - SABnzbd queue slots * @param {Object} context - Matching context with records, maps, and user info * @returns {Array} Array of matched download objects */ 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) { // Calculate progress from SABnzbd slot data const mbValue = slot.mb !== undefined && slot.mb !== null ? parseFloat(slot.mb) : 0; const mbLeftValue = (slot.mbleft !== undefined && slot.mbleft !== null) || (slot.mbmissing !== undefined && slot.mbmissing !== null) ? parseFloat(slot.mbleft || slot.mbmissing) : 0; const progress = mbValue > 0 ? ((mbValue - mbLeftValue) / mbValue) * 100 : 0; const dlObj = { type: 'series', title: nzbName, coverArt: DownloadAssembler.getCoverArt(series), status: slotState.status, progress: Math.round(progress), 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) { // Calculate progress from SABnzbd slot data const mbValue = slot.mb !== undefined && slot.mb !== null ? parseFloat(slot.mb) : 0; const mbLeftValue = (slot.mbleft !== undefined && slot.mbleft !== null) || (slot.mbmissing !== undefined && slot.mbmissing !== null) ? parseFloat(slot.mbleft || slot.mbmissing) : 0; const progress = mbValue > 0 ? ((mbValue - mbLeftValue) / mbValue) * 100 : 0; const dlObj = { type: 'movie', title: nzbName, coverArt: DownloadAssembler.getCoverArt(movie), status: slotState.status, progress: Math.round(progress), 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; } /** * Matches SABnzbd history slots to Sonarr/Radarr activity using title matching. * @param {Array} slots - SABnzbd history slots * @param {Object} context - Matching context with records, maps, and user info * @returns {Array} Array of matched download objects */ 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, progress: 100, // History items are completed 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, progress: 100, // History items are completed 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; } /** * Matches qBittorrent torrents to Sonarr/Radarr activity using title matching. * @param {Array} torrents - qBittorrent torrent list * @param {Object} context - Matching context with records, maps, and user info * @returns {Array} Array of matched download objects */ 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; } } } } return matched; } module.exports = { buildSeriesMapFromRecords, buildMoviesMapFromRecords, getSlotStatusAndSpeed, matchSabSlots, matchSabHistory, matchTorrents };