Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dcb77dd27f | |||
| f5315e5ceb | |||
| 13f3d767c5 |
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user