From 87387aaebe4f0485bc57ca1b0cab1bb753196ae2 Mon Sep 17 00:00:00 2001 From: Gronod Date: Fri, 29 May 2026 14:35:02 +0100 Subject: [PATCH] fix: resolve SABnzbd history matching asymmetry and unify search helpers (#74) --- server/services/DownloadMatcher.js | 151 +++++++++----------- tests/unit/services/DownloadMatcher.test.js | 37 +++++ 2 files changed, 102 insertions(+), 86 deletions(-) diff --git a/server/services/DownloadMatcher.js b/server/services/DownloadMatcher.js index 43b5d34..09c719a 100644 --- a/server/services/DownloadMatcher.js +++ b/server/services/DownloadMatcher.js @@ -53,6 +53,65 @@ function titleMatches(clientName, arrTitle, { isFallback = true, caller = 'Downl 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. @@ -237,46 +296,8 @@ async function matchSabSlots(slots, context) { const slotState = getSlotStatusAndSpeed(slot, queueStatus, queueSpeed, queueKbpersec); const nzbNameLower = nzbName.toLowerCase(); - // 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; - return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabSlots' }); - }); - } - if (!radarrMatch) { - radarrMatch = radarrQueueRecords.find(r => { - const rTitle = r.title || r.sourceTitle; - return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabSlots' }); - }); - } - - // Also check HISTORY (completed downloads) if no queue match - if (!sonarrMatch) { - sonarrMatch = sonarrHistoryRecords.find(r => { - const rTitle = r.title || r.sourceTitle; - return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabSlots' }); - }); - } - if (!radarrMatch) { - radarrMatch = radarrHistoryRecords.find(r => { - const rTitle = r.title || r.sourceTitle; - return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabSlots' }); - }); - } + const { sonarrMatch, radarrMatch } = findSabMatch(sabDownloadId, nzbName, context, 'matchSabSlots'); // Progress calculation const mbValue = slot.mb !== undefined && slot.mb !== null ? parseFloat(slot.mb) : 0; @@ -322,63 +343,20 @@ async function matchSabSlots(slots, context) { 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 - */ async function matchSabHistory(slots, context) { - const { - sonarrQueueRecords, - sonarrHistoryRecords, - radarrQueueRecords, - radarrHistoryRecords - } = context; - const matched = []; + for (const slot of slots) { const nzbName = slot.name || slot.nzb_name || slot.nzbname; if (!nzbName) continue; - const nzbNameLower = nzbName.toLowerCase(); - // Try to match by downloadId (nzo_id or slot ID) first (most reliable) const sabDownloadId = slot.nzo_id || slot.id; - const matchesSabId = (r) => { - const dl = r && r.downloadId; - if (!dl || !sabDownloadId) return false; - return String(dl).toLowerCase() === String(sabDownloadId).toLowerCase(); - }; - - let sonarrMatch = sabDownloadId ? sonarrHistoryRecords.find(matchesSabId) : null; - let radarrMatch = sabDownloadId ? radarrHistoryRecords.find(matchesSabId) : null; - - // Dual-lookup: also try against active queue records (history slot may still be in *arr queue) - if (!sonarrMatch && sabDownloadId) { - sonarrMatch = sonarrQueueRecords.find(matchesSabId); - } - if (!radarrMatch && sabDownloadId) { - radarrMatch = radarrQueueRecords.find(matchesSabId); - } - - // Fallback: Check by title matching - if (!sonarrMatch) { - sonarrMatch = sonarrHistoryRecords.find(r => { - const rTitle = r.title || r.sourceTitle; - return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabHistory' }); - }); - } - if (!radarrMatch) { - radarrMatch = radarrHistoryRecords.find(r => { - const rTitle = r.title || r.sourceTitle; - return rTitle && titleMatches(nzbName, rTitle, { isFallback: true, caller: 'matchSabHistory' }); - }); - } + const { sonarrMatch, radarrMatch } = findSabMatch(sabDownloadId, nzbName, context, 'matchSabHistory'); const commonOptions = { title: nzbName, status: slot.status || 'Completed', - progress: 100, // History items are completed + progress: 100, mb: slot.mb, size: Math.round((slot.mb || 0) * 1024 * 1024), completedAt: slot.completed_time, @@ -392,7 +370,7 @@ async function matchSabHistory(slots, context) { const dlObj = buildArrDownload(sonarrMatch, context, { ...commonOptions, arrType: 'sonarr', - episodes: DownloadAssembler.gatherEpisodes(nzbNameLower, sonarrHistoryRecords) + episodes: DownloadAssembler.gatherEpisodes(nzbName.toLowerCase(), context.sonarrHistoryRecords || []) }); if (dlObj) matched.push(dlObj); } @@ -405,6 +383,7 @@ async function matchSabHistory(slots, context) { if (dlObj) matched.push(dlObj); } } + return matched; } diff --git a/tests/unit/services/DownloadMatcher.test.js b/tests/unit/services/DownloadMatcher.test.js index 5503ece..038470e 100644 --- a/tests/unit/services/DownloadMatcher.test.js +++ b/tests/unit/services/DownloadMatcher.test.js @@ -226,6 +226,43 @@ describe('DownloadMatcher', () => { expect(result).toHaveLength(1); expect(result[0].arrQueueId).toBe(101); }); + + it('falls back to title matching against Sonarr queue records when downloadId is absent/unmatched', async () => { + const testContext = { + ...context, + sonarrHistoryRecords: [], + sonarrQueueRecords: [ + { id: 201, seriesId: 1, title: 'My.Cool.Show.S01E01.720p-Group' } + ], + seriesMap: new Map([[1, { id: 1, title: 'My Cool Show', tags: [1] }]]) + }; + + const slots = [{ id: null, name: 'My_Cool_Show_S01E01_720p-Group.nzb', status: 'Completed', mb: 1000 }]; + const result = await DownloadMatcher.matchSabHistory(slots, testContext); + + expect(result).toHaveLength(1); + expect(result[0].arrQueueId).toBe(201); + expect(result[0].arrType).toBe('sonarr'); + }); + + it('falls back to title matching against Radarr queue records when downloadId is absent/unmatched', async () => { + const testContext = { + ...context, + radarrHistoryRecords: [], + radarrQueueRecords: [ + { id: 301, movieId: 2, title: 'Awesome Movie 2026 1080p' } + ], + moviesMap: new Map([[2, { id: 2, title: 'Awesome Movie', tags: [1] }]]), + radarrTagMap: new Map([[1, 'alice']]) + }; + + const slots = [{ id: null, name: 'Awesome.Movie.2026.1080p.nzb', status: 'Completed', mb: 1000 }]; + const result = await DownloadMatcher.matchSabHistory(slots, testContext); + + expect(result).toHaveLength(1); + expect(result[0].arrQueueId).toBe(301); + expect(result[0].arrType).toBe('radarr'); + }); }); describe('titleMatches helper', () => {