Compare commits

...

3 Commits

Author SHA1 Message Date
gronod dcb77dd27f merge branch 'develop' into 'main' - Release v1.7.29
CI / Security audit (push) Successful in 2m47s
Create Release / release (push) Successful in 40s
CI / Swagger Validation & Coverage (push) Successful in 1m55s
Build and Push Docker Image / build (push) Successful in 1m19s
CI / Tests & coverage (push) Successful in 3m32s
2026-05-27 23:51:12 +01:00
gronod f5315e5ceb chore: bump version to 1.7.29 and update CHANGELOG and docs
Build and Push Docker Image / build (push) Successful in 1m39s
Docs Check / Markdown lint (push) Failing after 1m49s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m11s
CI / Swagger Validation & Coverage (push) Successful in 2m51s
CI / Security audit (push) Successful in 3m9s
Docs Check / Mermaid diagram parse check (push) Successful in 3m21s
CI / Tests & coverage (push) Failing after 3m44s
2026-05-27 23:46:40 +01:00
gronod 13f3d767c5 fix: resolve missing Radarr and Sonarr links on active downloads (fixes #59) 2026-05-27 23:46:35 +01:00
8 changed files with 149 additions and 7 deletions
+6
View File
@@ -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/). 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). 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 ## [1.7.28] - 2026-05-27
### Fixed ### Fixed
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sofarr", "name": "sofarr",
"version": "1.7.28", "version": "1.7.29",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sofarr", "name": "sofarr",
"version": "1.7.28", "version": "1.7.29",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.6.0", "axios": "^1.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "sofarr", "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", "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", "main": "server/index.js",
"scripts": { "scripts": {
+1 -1
View File
@@ -133,7 +133,7 @@ function createApp({ skipRateLimits = false } = {}) {
* version: * version:
* type: string * type: string
* description: sofarr version * description: sofarr version
* example: "1.7.28" * example: "1.7.29"
* x-code-samples: * x-code-samples:
* - lang: curl * - lang: curl
* label: cURL * label: cURL
+1 -1
View File
@@ -22,7 +22,7 @@ info:
## SSE Streaming ## SSE Streaming
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream. Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
version: 1.7.28 version: 1.7.29
contact: contact:
name: sofarr name: sofarr
license: license:
+9 -1
View File
@@ -13,7 +13,7 @@ const { buildUserDownloads } = require('../services/DownloadBuilder');
const { onHistoryUpdate, offHistoryUpdate } = require('../utils/historyFetcher'); const { onHistoryUpdate, offHistoryUpdate } = require('../utils/historyFetcher');
const arrRetrieverRegistry = require('../utils/arrRetrievers'); const arrRetrieverRegistry = require('../utils/arrRetrievers');
const { getOmbiInstances, getSonarrInstances, getRadarrInstances } = require('../utils/config'); 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'); const { canBlocklist } = require('../services/DownloadAssembler');
@@ -218,6 +218,10 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
ombiBaseUrl ombiBaseUrl
}); });
if (isAdmin) {
await decorateDownloadsWithArrLinks(userDownloads, isAdmin);
}
res.json({ res.json({
user: user.name, user: user.name,
isAdmin, isAdmin,
@@ -513,6 +517,10 @@ router.get('/stream', requireAuth, async (req, res) => {
ombiBaseUrl ombiBaseUrl
}); });
if (isAdmin) {
await decorateDownloadsWithArrLinks(userDownloads, isAdmin);
}
console.log(`[SSE] Sending ${userDownloads.length} downloads for ${user.name}`); console.log(`[SSE] Sending ${userDownloads.length} downloads for ${user.name}`);
const downloadClients = downloadClientRegistry.getAllClients().map(c => ({ const downloadClients = downloadClientRegistry.getAllClients().map(c => ({
id: c.getInstanceId(), id: c.getInstanceId(),
+88 -1
View File
@@ -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 = { module.exports = {
extractRequestedUser, extractRequestedUser,
filterRequestsByUser, filterRequestsByUser,
decorateRequestsWithArrLinks decorateRequestsWithArrLinks,
decorateDownloadsWithArrLinks
}; };
+41
View File
@@ -563,6 +563,47 @@ describe('GET /api/dashboard/user-downloads', () => {
expect(dl.canBlocklist).toBe(true); 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');
});
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------