From 13f3d767c565ed94feb872022074c208476b513f Mon Sep 17 00:00:00 2001 From: Gronod Date: Wed, 27 May 2026 23:46:35 +0100 Subject: [PATCH] fix: resolve missing Radarr and Sonarr links on active downloads (fixes #59) --- server/routes/dashboard.js | 10 +++- server/utils/ombiHelpers.js | 89 ++++++++++++++++++++++++++++- tests/integration/dashboard.test.js | 41 +++++++++++++ 3 files changed, 138 insertions(+), 2 deletions(-) diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js index 1b78d7e..4fd882b 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -13,7 +13,7 @@ const { buildUserDownloads } = require('../services/DownloadBuilder'); const { onHistoryUpdate, offHistoryUpdate } = require('../utils/historyFetcher'); const arrRetrieverRegistry = require('../utils/arrRetrievers'); const { getOmbiInstances, getSonarrInstances, getRadarrInstances } = require('../utils/config'); -const { extractRequestedUser, filterRequestsByUser, decorateRequestsWithArrLinks } = require('../utils/ombiHelpers'); +const { extractRequestedUser, filterRequestsByUser, decorateRequestsWithArrLinks, decorateDownloadsWithArrLinks } = require('../utils/ombiHelpers'); const { canBlocklist } = require('../services/DownloadAssembler'); @@ -218,6 +218,10 @@ router.get('/user-downloads', requireAuth, async (req, res) => { ombiBaseUrl }); + if (isAdmin) { + await decorateDownloadsWithArrLinks(userDownloads, isAdmin); + } + res.json({ user: user.name, isAdmin, @@ -513,6 +517,10 @@ router.get('/stream', requireAuth, async (req, res) => { ombiBaseUrl }); + if (isAdmin) { + await decorateDownloadsWithArrLinks(userDownloads, isAdmin); + } + console.log(`[SSE] Sending ${userDownloads.length} downloads for ${user.name}`); const downloadClients = downloadClientRegistry.getAllClients().map(c => ({ id: c.getInstanceId(), diff --git a/server/utils/ombiHelpers.js b/server/utils/ombiHelpers.js index 86d288b..6b1a13e 100644 --- a/server/utils/ombiHelpers.js +++ b/server/utils/ombiHelpers.js @@ -145,8 +145,95 @@ async function decorateRequestsWithArrLinks(requests, isAdmin) { }); } +async function decorateDownloadsWithArrLinks(downloads, isAdmin) { + if (!isAdmin || !Array.isArray(downloads)) return; + + const arrRetrieverRegistry = require('./arrRetrievers'); + await arrRetrieverRegistry.initialize(); + + const sonarrRetrievers = arrRetrieverRegistry.getRetrieversByType('sonarr') || []; + const radarrRetrievers = arrRetrieverRegistry.getRetrieversByType('radarr') || []; + + const [sonarrData, radarrData] = await Promise.all([ + Promise.all(sonarrRetrievers.map(async r => { + try { + const response = await require('axios').get(`${r.url}/api/v3/series`, { + headers: { 'X-Api-Key': r.apiKey } + }); + return { instance: r, series: response.data || [] }; + } catch { + return { instance: r, series: [] }; + } + })), + Promise.all(radarrRetrievers.map(async r => { + try { + const response = await require('axios').get(`${r.url}/api/v3/movie`, { + headers: { 'X-Api-Key': r.apiKey } + }); + return { instance: r, movies: response.data || [] }; + } catch { + return { instance: r, movies: [] }; + } + })) + ]); + + downloads.forEach(dl => { + // Determine if it's TV (series) or Movie + const isTv = dl.type === 'series' || dl.arrType === 'sonarr'; + + if (isTv) { + // Look for a match in Sonarr instances + for (const instData of sonarrData) { + const match = instData.series.find(s => { + if (!s) return false; + // Match by database series ID if the instance matches + const instanceUrlMatch = dl.arrInstanceUrl === instData.instance.url; + if (instanceUrlMatch && dl.arrSeriesId != null && s.id === parseInt(dl.arrSeriesId, 10)) { + return true; + } + // Fallback to seriesName matching + if (dl.seriesName && s.title && dl.seriesName.toLowerCase() === s.title.toLowerCase()) { + return true; + } + return false; + }); + + if (match && match.titleSlug) { + dl.arrLink = `${instData.instance.url}/series/${match.titleSlug}`; + dl.arrType = 'sonarr'; + break; + } + } + } else if (dl.type === 'movie' || dl.arrType === 'radarr') { + // Look for a match in Radarr instances + for (const instData of radarrData) { + const match = instData.movies.find(m => { + if (!m) return false; + // Match by database movie ID if instance matches + const instanceUrlMatch = dl.arrInstanceUrl === instData.instance.url; + if (instanceUrlMatch && dl.arrContentId != null && m.id === parseInt(dl.arrContentId, 10)) { + return true; + } + // Fallback to movieName matching + if (dl.movieName && m.title && dl.movieName.toLowerCase() === m.title.toLowerCase()) { + return true; + } + return false; + }); + + if (match && match.titleSlug) { + dl.arrLink = `${instData.instance.url}/movie/${match.titleSlug}`; + dl.arrType = 'radarr'; + break; + } + } + } + }); +} + module.exports = { extractRequestedUser, filterRequestsByUser, - decorateRequestsWithArrLinks + decorateRequestsWithArrLinks, + decorateDownloadsWithArrLinks }; diff --git a/tests/integration/dashboard.test.js b/tests/integration/dashboard.test.js index ae1aaad..a932562 100644 --- a/tests/integration/dashboard.test.js +++ b/tests/integration/dashboard.test.js @@ -563,6 +563,47 @@ describe('GET /api/dashboard/user-downloads', () => { expect(dl.canBlocklist).toBe(true); }); }); + + describe('Radarr/Sonarr deep-links decoration (Issue #59)', () => { + it('decorates active series downloads with Sonarr links for administrator', async () => { + const app = createApp({ skipRateLimits: true }); + const { cookies } = await loginAs(app, EMBY_ADMIN_USER, EMBY_ADMIN_AUTH); + + // Seed cache: queue record exists and matches SABnzbd slot + cache.set('poll:sab-queue', { slots: [ADMIN_SAB_SLOT], status: 'Downloading', speed: '10 MB/s' }, CACHE_TTL); + cache.set('poll:sab-history', { slots: [] }, CACHE_TTL); + cache.set('poll:sonarr-queue', { records: [ADMIN_SONARR_QUEUE_RECORD] }, CACHE_TTL); + cache.set('poll:sonarr-history', { records: [] }, CACHE_TTL); + cache.set('poll:sonarr-tags', [{ data: SONARR_TAGS }], CACHE_TTL); + cache.set('poll:radarr-queue', { records: [] }, CACHE_TTL); + cache.set('poll:radarr-history', { records: [] }, CACHE_TTL); + cache.set('poll:radarr-tags', RADARR_TAGS, CACHE_TTL); + cache.set('poll:qbittorrent', [], CACHE_TTL); + + // Mock Sonarr /api/v3/series response carrying titleSlug and seriesId matching our record + nock(SONARR_BASE) + .get('/api/v3/series') + .reply(200, [ + { id: 43, title: 'Admin Show', titleSlug: 'admin-show' } + ]); + + // Mock Radarr /api/v3/movie response + nock(RADARR_BASE) + .get('/api/v3/movie') + .reply(200, []); + + const res = await request(app) + .get('/api/dashboard/user-downloads') + .set('Cookie', cookies); + + expect(res.status).toBe(200); + const downloads = res.body.downloads; + const dl = downloads.find(d => d.type === 'series'); + expect(dl).toBeDefined(); + expect(dl.arrLink).toBe('https://sonarr.test/series/admin-show'); + expect(dl.arrType).toBe('sonarr'); + }); + }); }); // ---------------------------------------------------------------------------