From 13f3d767c565ed94feb872022074c208476b513f Mon Sep 17 00:00:00 2001 From: Gronod Date: Wed, 27 May 2026 23:46:35 +0100 Subject: [PATCH 1/2] 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'); + }); + }); }); // --------------------------------------------------------------------------- From f5315e5cebfdcf0f12e09aad88d1243a52a1645a Mon Sep 17 00:00:00 2001 From: Gronod Date: Wed, 27 May 2026 23:46:40 +0100 Subject: [PATCH 2/2] chore: bump version to 1.7.29 and update CHANGELOG and docs --- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- server/app.js | 2 +- server/openapi.yaml | 2 +- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3abcd15..810801e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.7.29] - 2026-05-27 + +### Fixed + +- **Missing Radarr/Sonarr Links on Active Downloads (Issue #59)** — Resolved a bug where Radarr and Sonarr deep-link buttons were missing from active/completed download cards on the main dashboard for administrator users. Implemented a generic link enrichment utility `decorateDownloadsWithArrLinks()` to query Radarr and Sonarr catalog APIs in parallel and match downloads via their database ID or title, circumventing minimal metadata in cached records that lacked `titleSlug`. Added a robust integration test to safeguard links decoration for dashboard downloads. + ## [1.7.28] - 2026-05-27 ### Fixed diff --git a/package-lock.json b/package-lock.json index 1ce86b2..43aa59f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sofarr", - "version": "1.7.28", + "version": "1.7.29", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sofarr", - "version": "1.7.28", + "version": "1.7.29", "license": "MIT", "dependencies": { "axios": "^1.6.0", diff --git a/package.json b/package.json index 0ace474..df1094f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sofarr", - "version": "1.7.28", + "version": "1.7.29", "description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish", "main": "server/index.js", "scripts": { diff --git a/server/app.js b/server/app.js index 850b13a..d54a0e0 100644 --- a/server/app.js +++ b/server/app.js @@ -133,7 +133,7 @@ function createApp({ skipRateLimits = false } = {}) { * version: * type: string * description: sofarr version - * example: "1.7.28" + * example: "1.7.29" * x-code-samples: * - lang: curl * label: cURL diff --git a/server/openapi.yaml b/server/openapi.yaml index c5abb04..8bd1e8c 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -22,7 +22,7 @@ info: ## SSE Streaming Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream. - version: 1.7.28 + version: 1.7.29 contact: name: sofarr license: