// 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'); const { logToFile } = require('../utils/logger'); const logger = { debug: (msg) => logToFile(`[DEBUG] ${msg}`) }; /** * Normalizes a title by lowercase conversion and replacing periods/underscores/dashes with spaces. * @param {string} str - The title to normalize * @returns {string} Normalized title */ function normalizeTitle(str) { if (!str) return ''; return String(str) .toLowerCase() .replace(/\./g, ' ') .replace(/[\-_]/g, ' ') .replace(/\s+/g, ' ') .trim(); } /** * Compares a download client item name with a *arr title by checking both raw * and normalized (dots/dashes/underscores to spaces) forms bidirectionally. * Only logs on title fallback matches (when isFallback=true) to keep logs clean. */ function titleMatches(clientName, arrTitle, { isFallback = true, caller = 'DownloadMatcher' } = {}) { if (!clientName || !arrTitle) return false; const a = clientName.toLowerCase(); const b = arrTitle.toLowerCase(); const aNorm = normalizeTitle(clientName); const bNorm = normalizeTitle(arrTitle); const matched = a.includes(b) || b.includes(a) || aNorm.includes(bNorm) || bNorm.includes(aNorm) || aNorm.includes(b) || b.includes(aNorm) || a.includes(bNorm) || bNorm.includes(a); if (matched && isFallback) { logger.debug(`[DownloadMatcher] Title fallback match in ${caller} after normalization: "${clientName}" <-> "${arrTitle}"`); } return matched; } /** * Internal helper: Finds the best matching Sonarr or Radarr record for a SABnzbd slot. * Performs robust case-insensitive downloadId matching (queue → history), * then bidirectional title fallback (queue → history). * This eliminates all duplication and asymmetry between matchSabSlots and matchSabHistory. * * @param {string|null} sabDownloadId * @param {string} nzbName * @param {Object} context * @param {string} caller - e.g. 'matchSabHistory' or 'matchSabSlots' * @returns {{ sonarrMatch: Object|null, radarrMatch: Object|null }} */ function findSabMatch(sabDownloadId, nzbName, context, caller = 'DownloadMatcher') { const { sonarrQueueRecords = [], sonarrHistoryRecords = [], radarrQueueRecords = [], radarrHistoryRecords = [] } = context; const findBest = (queueRecords, historyRecords) => { // 1. Robust ID match (queue first) let match = sabDownloadId ? queueRecords.find(r => { const dl = r && r.downloadId; return dl && String(dl).toLowerCase() === String(sabDownloadId).toLowerCase(); }) : null; if (!match && sabDownloadId) { match = historyRecords.find(r => { const dl = r && r.downloadId; return dl && String(dl).toLowerCase() === String(sabDownloadId).toLowerCase(); }); } // 2. Title fallback (queue first, then history) if (!match && nzbName) { match = queueRecords.find(r => { const rTitle = r && (r.title || r.sourceTitle); return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller }); }); } if (!match && nzbName) { match = historyRecords.find(r => { const rTitle = r && (r.title || r.sourceTitle); return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller }); }); } return match || null; }; return { sonarrMatch: findBest(sonarrQueueRecords, sonarrHistoryRecords), radarrMatch: findBest(radarrQueueRecords, radarrHistoryRecords) }; } /** * All callers (matchers + orphan path) must supply client, instanceId, and instanceName via options. * Defaults exist only as a last-resort safety net. * * @example * buildArrDownload(sonarrMatch, context, { client: 'sabnzbd', instanceId: 'sabnzbd-default', instanceName: 'SABnzbd' }) */ function buildArrDownload(record, context, options = {}) { const { seriesMap, moviesMap, sonarrTagMap, radarrTagMap, username, isAdmin, showAll, embyUserMap } = context; // Detect if sonarr or radarr record const isSeries = record.seriesId !== undefined || options.arrType === 'sonarr'; const mediaMap = isSeries ? seriesMap : moviesMap; const tagMap = isSeries ? sonarrTagMap : radarrTagMap; const mediaId = isSeries ? record.seriesId : record.movieId; const media = mediaMap.get(mediaId) || record.series || record.movie; if (!media) return null; // Tag-based user filtering const allTags = TagMatcher.extractAllTags(media.tags, tagMap); const matchedUserTag = TagMatcher.extractUserTag(media.tags, tagMap, username); if (!showAll && !matchedUserTag) return null; // Safer default progress of 0 for items that haven't started yet const progress = options.progress !== undefined ? options.progress : 0; const dlObj = { type: isSeries ? 'series' : 'movie', title: options.title || record.title || record.sourceTitle, coverArt: DownloadAssembler.getCoverArt(media), status: options.status || record.status || 'Unknown', progress, mb: options.mb !== undefined ? options.mb : (record.size ? Math.round(record.size / 1024 / 1024) : 0), size: options.size !== undefined ? options.size : (record.size || 0), completedAt: options.completedAt || record.completed_time || null, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? TagMatcher.buildTagBadges(allTags, embyUserMap) : undefined, // Strict neutral defaults to avoid incorrect SABnzbd-centric data client: options.client || 'orphaned', instanceId: options.instanceId || 'orphaned', instanceName: options.instanceName || 'Unknown', ...options.overrides }; if (isSeries) { dlObj.seriesName = media.title; dlObj.episodes = options.episodes || []; } else { dlObj.movieName = media.title; dlObj.movieInfo = record; } const issues = DownloadAssembler.getImportIssues(record); if (issues) dlObj.importIssues = issues; dlObj.arrQueueId = record.id; dlObj.arrType = isSeries ? 'sonarr' : 'radarr'; dlObj.arrInstanceUrl = record._instanceUrl || null; dlObj.arrContentId = record.episodeId || record.movieId || null; dlObj.arrContentIds = record.episodeIds || null; dlObj.arrSeriesId = record.seriesId || null; dlObj.arrContentType = isSeries ? 'episode' : 'movie'; // Use correct blocklist determination dlObj.canBlocklist = DownloadAssembler.canBlocklist(dlObj, isAdmin); if (isAdmin) { dlObj.downloadPath = options.downloadPath || null; dlObj.targetPath = media.path || null; dlObj.arrInstanceKey = record._instanceKey || null; dlObj.arrLink = isSeries ? DownloadAssembler.getSonarrLink(media) : DownloadAssembler.getRadarrLink(media); } addOmbiMatching(dlObj, media, context); return dlObj; } /** * 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; } /** * Adds an Ombi details link to a download object using the TMDB ID from the *arr media object. * No Ombi API call is required — the link is built directly from the TMDB ID. * @param {Object} downloadObj - Download object to enhance * @param {Object} seriesOrMovie - Series or movie object from Sonarr/Radarr * @param {Object} context - Context containing ombiBaseUrl */ function addOmbiMatching(downloadObj, seriesOrMovie, context) { const { ombiBaseUrl } = context; const link = DownloadAssembler.getOmbiDetailsLink(seriesOrMovie, downloadObj.type, ombiBaseUrl); if (link) { downloadObj.ombiLink = link; downloadObj.ombiTooltip = 'View in Ombi'; } } /** * 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 */ async function matchSabSlots(slots, context) { const { sonarrQueueRecords, sonarrHistoryRecords, radarrQueueRecords, radarrHistoryRecords, 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(); const sabDownloadId = slot.nzo_id || slot.id; const { sonarrMatch, radarrMatch } = findSabMatch(sabDownloadId, nzbName, context, 'matchSabSlots'); // Progress calculation 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 commonOptions = { title: nzbName, status: slotState.status, progress: Math.round(progress), mb: slot.mb, size: Math.round(slot.mb * 1024 * 1024), client: 'sabnzbd', instanceId: slot.instanceId || 'sabnzbd-default', instanceName: slot.instanceName || 'SABnzbd', downloadPath: slot.storage || null, overrides: { mbmissing: slot.mbleft, speed: Math.round((slot.kbpersec || 0) * 1024), eta: slot.timeleft } }; if (sonarrMatch && sonarrMatch.seriesId) { const dlObj = buildArrDownload(sonarrMatch, context, { ...commonOptions, arrType: 'sonarr', episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrQueueRecords) }); if (dlObj) matched.push(dlObj); } if (radarrMatch && radarrMatch.movieId) { const dlObj = buildArrDownload(radarrMatch, context, { ...commonOptions, arrType: 'radarr' }); if (dlObj) matched.push(dlObj); } } return matched; } async function matchSabHistory(slots, context) { const matched = []; for (const slot of slots) { const nzbName = slot.name || slot.nzb_name || slot.nzbname; if (!nzbName) continue; const sabDownloadId = slot.nzo_id || slot.id; const { sonarrMatch, radarrMatch } = findSabMatch(sabDownloadId, nzbName, context, 'matchSabHistory'); const commonOptions = { title: nzbName, status: slot.status || 'Completed', progress: 100, mb: slot.mb, size: Math.round((slot.mb || 0) * 1024 * 1024), completedAt: slot.completed_time, client: 'sabnzbd', instanceId: slot.instanceId || 'sabnzbd-default', instanceName: slot.instanceName || 'SABnzbd', downloadPath: slot.storage || null }; if (sonarrMatch && sonarrMatch.seriesId) { const dlObj = buildArrDownload(sonarrMatch, context, { ...commonOptions, arrType: 'sonarr', episodes: DownloadAssembler.gatherEpisodes(nzbName.toLowerCase(), context.sonarrHistoryRecords || []) }); if (dlObj) matched.push(dlObj); } if (radarrMatch && radarrMatch.movieId) { const dlObj = buildArrDownload(radarrMatch, context, { ...commonOptions, arrType: 'radarr' }); if (dlObj) 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 */ async function matchTorrents(torrents, context) { const { sonarrQueueRecords, sonarrHistoryRecords, radarrQueueRecords, radarrHistoryRecords } = context; const matched = []; for (const torrent of torrents) { const torrentName = torrent.name || ''; if (!torrentName) continue; const torrentNameLower = torrentName.toLowerCase(); // Hash-first matching (Issue #65) const torrentHash = torrent?.hash || torrent?.hashString || null; const hashLower = torrentHash ? String(torrentHash).toLowerCase() : null; const matchesByHash = (r) => { const dl = r && r.downloadId; if (!dl || !hashLower) return false; return String(dl).toLowerCase() === hashLower; }; let sonarrMatch = hashLower ? sonarrQueueRecords.find(matchesByHash) : null; let radarrMatch = hashLower ? radarrQueueRecords.find(matchesByHash) : null; // Fallback: Check by title matching if (!sonarrMatch) { sonarrMatch = sonarrQueueRecords.find(r => { const rTitle = r.title || r.sourceTitle; return rTitle && titleMatches(torrentName, rTitle, { isFallback: true, caller: 'matchTorrents' }); }); } if (!radarrMatch) { radarrMatch = radarrQueueRecords.find(r => { const rTitle = r.title || r.sourceTitle; return rTitle && titleMatches(torrentName, rTitle, { isFallback: true, caller: 'matchTorrents' }); }); } // Fallback to history matching let sonarrHistoryMatch = hashLower ? sonarrHistoryRecords.find(matchesByHash) : null; let radarrHistoryMatch = hashLower ? radarrHistoryRecords.find(matchesByHash) : null; if (!sonarrHistoryMatch) { sonarrHistoryMatch = sonarrHistoryRecords.find(r => { const rTitle = r.title || r.sourceTitle; return rTitle && titleMatches(torrentName, rTitle, { isFallback: true, caller: 'matchTorrents' }); }); } if (!radarrHistoryMatch) { radarrHistoryMatch = radarrHistoryRecords.find(r => { const rTitle = r.title || r.sourceTitle; return rTitle && titleMatches(torrentName, rTitle, { isFallback: true, caller: 'matchTorrents' }); }); } // Helper options for torrent mapping const download = mapTorrentToDownload(torrent); const progress = parseFloat(download.progress) || torrent.progress || 0; const speed = download.rawSpeed || torrent.dlspeed || 0; const commonOptions = { title: torrentName, status: download.status || torrent.status || 'Downloading', progress: Math.round(progress), mb: download.size ? Math.round(download.size / 1024 / 1024) : 0, size: download.size || torrent.size || 0, client: download.client || 'qbittorrent', instanceId: torrent.instanceId || download.instanceId || 'qbittorrent-default', instanceName: torrent.instanceName || download.instanceName || 'qBittorrent', downloadPath: download.savePath || torrent.savePath || null, overrides: { id: download.hash || torrent.hash, speed, eta: torrent.eta, seeds: torrent.seeds, peers: torrent.peers, availability: torrent.availability, addedOn: torrent.addedOn, qbittorrent: true } }; if (sonarrMatch && sonarrMatch.seriesId) { const dlObj = buildArrDownload(sonarrMatch, context, { ...commonOptions, arrType: 'sonarr', episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrQueueRecords) }); if (dlObj) matched.push(dlObj); } if (radarrMatch && radarrMatch.movieId) { const dlObj = buildArrDownload(radarrMatch, context, { ...commonOptions, arrType: 'radarr' }); if (dlObj) matched.push(dlObj); } if (sonarrHistoryMatch && sonarrHistoryMatch.seriesId) { const dlObj = buildArrDownload(sonarrHistoryMatch, context, { ...commonOptions, arrType: 'sonarr', progress: 100, // completed episodes: DownloadAssembler.gatherEpisodes(torrentNameLower, sonarrHistoryRecords) }); if (dlObj) matched.push(dlObj); } if (radarrHistoryMatch && radarrHistoryMatch.movieId) { const dlObj = buildArrDownload(radarrHistoryMatch, context, { ...commonOptions, arrType: 'radarr', progress: 100 // completed }); if (dlObj) matched.push(dlObj); } } // Deduplicate by (arrType, arrQueueId) (Issue #65) const seen = new Set(); const deduped = []; for (const m of matched) { const key = (m && m.arrType && m.arrQueueId != null) ? `${m.arrType}:${m.arrQueueId}` : null; if (key) { if (seen.has(key)) continue; seen.add(key); } deduped.push(m); } return deduped; } /** * Matches orphaned *arr queue items that have no corresponding download client item * but still reside in the active Sonarr/Radarr queue. * @param {Set} matchedArrQueueIds - Already matched queue record IDs to skip * @param {Object} context - Matching context with records, maps, and user info * @returns {Array} Array of orphaned download objects */ function matchOrphanedArrRecords(matchedArrQueueIds, context) { const { sonarrQueueRecords, radarrQueueRecords } = context; const matched = []; // Deduplication Strategy: // We initialize the processed Set with already-matched IDs compiled during Phase 1 matching. // We also track newly processed IDs locally to handle situations where multiple duplicate queue // records pointing to the same downloadId exist in Sonarr/Radarr. const processedQueueIds = new Set(matchedArrQueueIds); const processRecords = (records, arrType) => { for (const record of records) { if (processedQueueIds.has(record.id)) continue; processedQueueIds.add(record.id); // Safe progress arithmetic to prevent NaN or division-by-zero const size = record.size || 0; const sizeleft = record.sizeleft || 0; const progress = size > 0 ? Math.round(100 - (sizeleft / size * 100)) : 0; const status = record.trackedDownloadStatus || record.status || 'Unknown'; const dl = buildArrDownload(record, context, { arrType, status, progress, client: 'orphaned', instanceId: 'orphaned', instanceName: 'Orphaned (unconfigured client)', overrides: { isOrphaned: true } }); if (dl) { logger.debug(`[DownloadMatcher] Found orphaned *arr queue item: ${dl.title} (arrQueueId=${record.id})`); matched.push(dl); } } }; processRecords(sonarrQueueRecords || [], 'sonarr'); processRecords(radarrQueueRecords || [], 'radarr'); return matched; } module.exports = { buildSeriesMapFromRecords, buildMoviesMapFromRecords, getSlotStatusAndSpeed, addOmbiMatching, matchSabSlots, matchSabHistory, matchTorrents, buildArrDownload, matchOrphanedArrRecords };