From d9897ff0d2d882a42a481f6cb61e94540cc7696b Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 21 May 2026 00:04:57 +0100 Subject: [PATCH] Extract matching logic into new DownloadMatcher service --- server/services/DownloadBuilder.js | 568 +--------------------------- server/services/DownloadMatcher.js | 578 +++++++++++++++++++++++++++++ 2 files changed, 582 insertions(+), 564 deletions(-) create mode 100644 server/services/DownloadMatcher.js diff --git a/server/services/DownloadBuilder.js b/server/services/DownloadBuilder.js index fce6509..6b57793 100644 --- a/server/services/DownloadBuilder.js +++ b/server/services/DownloadBuilder.js @@ -6,567 +6,7 @@ * a unified view of downloads for each user, matching downloads to media metadata via tags. */ -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) { - 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; -} - -/** - * 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, - 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; -} - -/** - * 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; - } - } - } - - // 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; -} +const DownloadMatcher = require('./DownloadMatcher'); /** * Builds a unified list of downloads for a user from multiple download clients. @@ -630,7 +70,7 @@ function buildUserDownloads(cacheSnapshot, { username, usernameSanitized, isAdmi const seenDownloadKeys = new Set(); if (sabnzbdQueue.data?.queue?.slots) { - const sabMatches = matchSabSlots(sabnzbdQueue.data.queue.slots, context); + const sabMatches = DownloadMatcher.matchSabSlots(sabnzbdQueue.data.queue.slots, context); for (const dl of sabMatches) { const key = `${dl.type}:${dl.title}`; if (!seenDownloadKeys.has(key)) { @@ -641,7 +81,7 @@ function buildUserDownloads(cacheSnapshot, { username, usernameSanitized, isAdmi } if (sabnzbdHistory.data?.history?.slots) { - const sabHistoryMatches = matchSabHistory(sabnzbdHistory.data.history.slots, context); + const sabHistoryMatches = DownloadMatcher.matchSabHistory(sabnzbdHistory.data.history.slots, context); for (const dl of sabHistoryMatches) { const key = `${dl.type}:${dl.title}`; if (!seenDownloadKeys.has(key)) { @@ -651,7 +91,7 @@ function buildUserDownloads(cacheSnapshot, { username, usernameSanitized, isAdmi } } - const torrentMatches = matchTorrents(qbittorrentTorrents, context); + const torrentMatches = DownloadMatcher.matchTorrents(qbittorrentTorrents, context); for (const dl of torrentMatches) { const key = `${dl.type}:${dl.title}`; if (!seenDownloadKeys.has(key)) { diff --git a/server/services/DownloadMatcher.js b/server/services/DownloadMatcher.js new file mode 100644 index 0000000..935f8aa --- /dev/null +++ b/server/services/DownloadMatcher.js @@ -0,0 +1,578 @@ +// 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) { + 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; +} + +/** + * 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, + 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; +} + +/** + * 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; + } + } + } + + // 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; +} + +module.exports = { + buildSeriesMapFromRecords, + buildMoviesMapFromRecords, + getSlotStatusAndSpeed, + matchSabSlots, + matchSabHistory, + matchTorrents +};