Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dcb77dd27f | |||
| f5315e5ceb | |||
| 13f3d767c5 | |||
| 6c3ffb9b77 | |||
| a37874c553 | |||
| 5933e09652 |
@@ -4,6 +4,18 @@ 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
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Missing Sonarr Link on TV Requests (Issue #58)** — Resolved a bug where the Sonarr deep-link button was missing on TV request cards while Radarr links correctly appeared on movie request cards. Added support for all camelCase TVDB ID variants (`tvDbId`, `tvdbId`, `theTvdbId`, `theTvDbId`, `TvDbId`, `TheTvDbId`) on both backend link decoration (`server/utils/ombiHelpers.js`) and frontend rendering (`client/src/ui/requests.js`). Added a dedicated integration test to safeguard links decoration for TV requests.
|
||||||
|
|
||||||
## [1.7.27] - 2026-05-27
|
## [1.7.27] - 2026-05-27
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ function createRequestCard(request) {
|
|||||||
const actions = document.createElement('span');
|
const actions = document.createElement('span');
|
||||||
actions.className = 'service-icons-container';
|
actions.className = 'service-icons-container';
|
||||||
|
|
||||||
const id = request.theTvDbId || request.theMovieDbId || request.theTvdbId || request.theTmdbId || request.TvDbId || request.TheTvDbId || request.imdbId || request.ImdbId;
|
const id = request.theTvDbId || request.theTvdbId || request.tvDbId || request.tvdbId || request.TvDbId || request.TheTvDbId || request.theMovieDbId || request.theTmdbId || request.imdbId || request.ImdbId;
|
||||||
if (state.ombiBaseUrl && id) {
|
if (state.ombiBaseUrl && id) {
|
||||||
const ombiLink = document.createElement('a');
|
const ombiLink = document.createElement('a');
|
||||||
ombiLink.className = 'ombi-link';
|
ombiLink.className = 'ombi-link';
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.27",
|
"version": "1.7.29",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.27",
|
"version": "1.7.29",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.27",
|
"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
@@ -133,7 +133,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
* version:
|
* version:
|
||||||
* type: string
|
* type: string
|
||||||
* description: sofarr version
|
* description: sofarr version
|
||||||
* example: "1.7.27"
|
* example: "1.7.29"
|
||||||
* x-code-samples:
|
* x-code-samples:
|
||||||
* - lang: curl
|
* - lang: curl
|
||||||
* label: cURL
|
* label: cURL
|
||||||
|
|||||||
+1
-1
@@ -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.27
|
version: 1.7.29
|
||||||
contact:
|
contact:
|
||||||
name: sofarr
|
name: sofarr
|
||||||
license:
|
license:
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -115,10 +115,10 @@ async function decorateRequestsWithArrLinks(requests, isAdmin) {
|
|||||||
requests.forEach(req => {
|
requests.forEach(req => {
|
||||||
// Determine if it's TV or Movie. Often `mediaType` is set, or `type === 'Tv'`
|
// Determine if it's TV or Movie. Often `mediaType` is set, or `type === 'Tv'`
|
||||||
// Fallback to checking for TV specific IDs.
|
// Fallback to checking for TV specific IDs.
|
||||||
const isTv = req.mediaType === 'tv' || req.type === 'Tv' || req.tvDbId || req.theTvDbId;
|
const isTv = req.mediaType === 'tv' || req.type === 'Tv' || req.tvDbId || req.tvdbId || req.theTvDbId || req.theTvdbId || req.TvDbId || req.TheTvDbId;
|
||||||
|
|
||||||
if (isTv) {
|
if (isTv) {
|
||||||
const tvdbId = req.theTvDbId || req.theMovieDbId || req.theTvdbId || req.theTmdbId || req.TvDbId || req.TheTvDbId;
|
const tvdbId = req.theTvDbId || req.theTvdbId || req.tvDbId || req.tvdbId || req.TvDbId || req.TheTvDbId || req.theMovieDbId || req.theTmdbId;
|
||||||
if (!tvdbId) return;
|
if (!tvdbId) return;
|
||||||
|
|
||||||
for (const instData of sonarrData) {
|
for (const instData of sonarrData) {
|
||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -233,6 +233,48 @@ describe('GET /api/ombi/requests', () => {
|
|||||||
expect(res.body.requests.movie[0].requestedUser.userName).toBe('adminuser');
|
expect(res.body.requests.movie[0].requestedUser.userName).toBe('adminuser');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('decorates TV requests with Sonarr links using tvDbId camelCase property (Issue #58)', async () => {
|
||||||
|
// 1. Setup mock instance config
|
||||||
|
process.env.SONARR_INSTANCES = JSON.stringify([
|
||||||
|
{ id: 'sonarr-1', name: 'Test Sonarr', url: 'https://sonarr.test', apiKey: 'sonarr-key' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Reset and re-initialize retrievers registry to pick up the Sonarr instance
|
||||||
|
arrRetrieverRegistry.retrievers.clear();
|
||||||
|
arrRetrieverRegistry.initialized = false;
|
||||||
|
|
||||||
|
// 2. Setup mock Ombi TV request carrying `tvDbId` instead of `theTvDbId`
|
||||||
|
const tvRequestsWithTvDbId = [
|
||||||
|
{ id: 4, title: 'Superman Show', requestedUser: { userName: 'adminuser' }, requestedByAlias: 'adminuser', type: 'tv', tvDbId: '101' }
|
||||||
|
];
|
||||||
|
|
||||||
|
nock.cleanAll();
|
||||||
|
setupOmbiRequestMocks(OMBI_REQUESTS.movie, tvRequestsWithTvDbId);
|
||||||
|
|
||||||
|
// 3. Mock Sonarr API series call returning the series with matching tvdbId and titleSlug
|
||||||
|
nock('https://sonarr.test')
|
||||||
|
.get('/api/v3/series')
|
||||||
|
.reply(200, [
|
||||||
|
{ tvdbId: 101, title: 'Superman Show', titleSlug: 'superman-show' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { cookies } = await authenticateUser(app, 'AdminUser', true);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/ombi/requests?showAll=true')
|
||||||
|
.set('Cookie', cookies)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
// 4. Assert decoration succeeded
|
||||||
|
const supermanShow = res.body.requests.tv.find(r => r.id === 4);
|
||||||
|
expect(supermanShow).toBeDefined();
|
||||||
|
expect(supermanShow.arrLink).toBe('https://sonarr.test/series/superman-show');
|
||||||
|
expect(supermanShow.arrType).toBe('sonarr');
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
delete process.env.SONARR_INSTANCES;
|
||||||
|
});
|
||||||
|
|
||||||
it('handles case-insensitive username matching', async () => {
|
it('handles case-insensitive username matching', async () => {
|
||||||
const requestsWithMixedCase = [
|
const requestsWithMixedCase = [
|
||||||
{ id: 1, title: 'Test Movie', requestedUser: { userName: 'TestUser' }, requestedByAlias: 'TestUser', type: 'movie' },
|
{ id: 1, title: 'Test Movie', requestedUser: { userName: 'TestUser' }, requestedByAlias: 'TestUser', type: 'movie' },
|
||||||
|
|||||||
Reference in New Issue
Block a user