Compare commits

..

11 Commits

Author SHA1 Message Date
gronod fb68bddedb merge branch 'develop' into 'main' - Update Release v1.7.19 to ignore scratch directory
Build and Push Docker Image / build (push) Successful in 1m8s
Create Release / release (push) Successful in 36s
CI / Tests & coverage (push) Successful in 1m55s
CI / Security audit (push) Successful in 2m12s
CI / Swagger Validation & Coverage (push) Successful in 1m50s
2026-05-25 08:33:16 +01:00
gronod 81d0aa82f2 chore: add scratch/ to gitignore and untrack existing scratch files
Build and Push Docker Image / build (push) Successful in 1m17s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 2m26s
CI / Swagger Validation & Coverage (push) Successful in 3m1s
CI / Security audit (push) Successful in 3m15s
CI / Tests & coverage (push) Successful in 3m42s
2026-05-25 08:32:33 +01:00
gronod 7d7304637c merge branch 'develop' into 'main' - Release v1.7.19
Create Release / release (push) Successful in 33s
Build and Push Docker Image / build (push) Successful in 2m19s
CI / Security audit (push) Successful in 2m14s
CI / Tests & coverage (push) Successful in 2m21s
CI / Swagger Validation & Coverage (push) Successful in 2m24s
2026-05-25 08:28:28 +01:00
gronod d87ad9f1c7 fix: mobile request card overflow (#49) and admin arrLink active badges (#50), bump version to 1.7.19
Build and Push Docker Image / build (push) Successful in 1m23s
Docs Check / Markdown lint (push) Successful in 1m42s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 2m33s
Docs Check / Mermaid diagram parse check (push) Successful in 3m19s
CI / Security audit (push) Successful in 3m21s
CI / Swagger Validation & Coverage (push) Successful in 3m40s
CI / Tests & coverage (push) Successful in 4m57s
2026-05-25 08:28:16 +01:00
gronod ce71be29c4 merge branch 'develop' into 'main' - Release v1.7.18
Create Release / release (push) Successful in 1m35s
Build and Push Docker Image / build (push) Successful in 1m42s
CI / Swagger Validation & Coverage (push) Successful in 2m58s
CI / Tests & coverage (push) Successful in 3m13s
CI / Security audit (push) Successful in 1m41s
2026-05-24 23:26:45 +01:00
gronod b8870ca6cf chore: bump version to 1.7.18 and update CHANGELOG and docs
CI / Tests & coverage (push) Failing after 33s
Docs Check / Markdown lint (push) Successful in 1m57s
Docs Check / Mermaid diagram parse check (push) Successful in 2m59s
CI / Security audit (push) Successful in 3m32s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 1m54s
Build and Push Docker Image / build (push) Successful in 1m32s
CI / Swagger Validation & Coverage (push) Successful in 2m34s
2026-05-24 23:26:27 +01:00
gronod 90837f6e3b fix: mobile overflow on Requests tab cards
- Remove white-space: nowrap from .request-title so long titles
  truncate/wrap instead of forcing cards beyond the viewport width
- Add overflow-x: hidden to .requests-list as a safety net
- Add @media (max-width: 768px) rules for the requests section:
  reduce .requests-container padding from 20px to 12px, tighten
  .request-card and .request-meta gaps on mobile

Fixes #49
2026-05-24 23:18:59 +01:00
gronod fbc071da09 merge branch 'develop' into 'main' - Release v1.7.17
Build and Push Docker Image / build (push) Successful in 46s
Create Release / release (push) Successful in 48s
CI / Swagger Validation & Coverage (push) Successful in 2m45s
CI / Security audit (push) Successful in 2m9s
CI / Tests & coverage (push) Failing after 32s
2026-05-24 22:49:16 +01:00
gronod 7690d959b3 fix: blocklist-search lookup against queue cache instead of downloadClientRegistry
CI / Security audit (push) Successful in 1m52s
Docs Check / Markdown lint (push) Successful in 1m37s
Build and Push Docker Image / build (push) Successful in 2m2s
Licence Check / Licence compatibility and copyright header verification (push) Failing after 2m33s
CI / Swagger Validation & Coverage (push) Successful in 3m17s
Docs Check / Mermaid diagram parse check (push) Successful in 3m31s
CI / Tests & coverage (push) Successful in 4m5s
Fixes the root cause of the regression from v1.7.16. The v1.7.16 fix
correctly cast arrQueueId to String, but the lookup was performed
against downloadClientRegistry.getAllDownloads() which returns raw
download client data (qBittorrent, SABnzbd, etc.) that never has
arrQueueId populated.

The fix now looks up the queue record directly from the Sonarr/Radarr
queue cache where record.id is the numeric queue ID, using String()
casting on both sides to handle the DOM-dataset (string) vs API
response (number) type difference.

Resolves Gitea Issue #48
Closes #48
2026-05-24 22:48:17 +01:00
gronod 1ba9d15954 merge branch 'develop' into 'main' - Release v1.7.16
Create Release / release (push) Successful in 21s
Build and Push Docker Image / build (push) Successful in 2m11s
CI / Security audit (push) Successful in 1m57s
CI / Tests & coverage (push) Successful in 2m29s
CI / Swagger Validation & Coverage (push) Successful in 2m6s
2026-05-24 22:13:28 +01:00
gronod 83c9d4d164 fix: blocklist-search queue ID type mismatch and bump version to 1.7.16
Build and Push Docker Image / build (push) Successful in 2m14s
Docs Check / Markdown lint (push) Successful in 2m29s
CI / Security audit (push) Successful in 2m56s
Licence Check / Licence compatibility and copyright header verification (push) Successful in 3m4s
CI / Swagger Validation & Coverage (push) Successful in 3m52s
Docs Check / Mermaid diagram parse check (push) Successful in 4m8s
CI / Tests & coverage (push) Successful in 4m38s
- Cast arrQueueId to String in both sides of the download lookup comparison
  in /api/dashboard/blocklist-search to resolve false-negative match failure
  caused by DOM dataset string vs Radarr/Sonarr API number type mismatch
- Add regression integration test for string-vs-number arrQueueId matching
- Bump version to 1.7.16, update CHANGELOG.md, openapi.yaml, and JSDoc examples

Resolves #48
2026-05-24 22:12:34 +01:00
11 changed files with 212 additions and 67 deletions
+2 -1
View File
@@ -11,4 +11,5 @@ data/
*.db-wal
*.db-shm
.agents/
.windsurf/
.windsurf/
scratch/
+33
View File
@@ -4,6 +4,39 @@ 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.19] - 2026-05-25
### Fixed
- **Requests Mobile CSS Overflow Fix (Issue #49)** — Resolved the remaining viewport overflow on narrow screens by adding `min-width: 0` to `.request-card` to allow proper flexbox and grid shrinking, reducing mobile `.main-tabs` padding to `0 8px`, and tightening `.requests-container` mobile padding to `8px`.
- **Admin *arr Badge Links on Active Downloads (Issue #50)** — Fixed the missing Sonarr/Radarr badges and links on active downloads for admin users. Enabled robust `_instanceUrl` propagation during queue/history metadata compilation (`buildMetadataMaps`) and active matching (`DownloadMatcher.js`) to ensure link generation succeeds. Added complete schema documentation in OpenAPI.
---
## [1.7.18] - 2026-05-24
### Fixed
- **Mobile overflow on Requests tab** — Request cards no longer extend off the right edge of the screen on mobile browsers. Removed `white-space: nowrap` from `.request-title` to allow text truncation with ellipsis, added `overflow-x: hidden` to `.requests-list` as a safety net, and added `@media (max-width: 768px)` rules to reduce padding and tighten gaps on mobile. Resolves Gitea Issue [#49](https://git.i3omb.com/Gandalf/sofarr/issues/49).
---
## [1.7.17] - 2026-05-24
### Fixed
- **Blocklist-Search Persistent Failure (Regression from v1.7.16)** — Identified and corrected the true root cause of the blocklist-and-search feature being non-functional. The v1.7.16 fix correctly cast both sides of the queue ID comparison to `String`, but the lookup was performed against `downloadClientRegistry.getAllDownloads()`, which returns **raw download-client data** (qBittorrent, SABnzbd, etc.) that never has `arrQueueId` populated — that field is only assigned by `DownloadMatcher.js` during the SSE build phase from the *arr cache. For qBittorrent torrents specifically, `QBittorrentClient.normalizeDownload()` does not set `arrQueueId` at all, so the lookup always returned `undefined` and the request was rejected with `403`. The permission check in `POST /api/dashboard/blocklist-search` now looks up the queue record directly from the Sonarr/Radarr queue cache (`poll:sonarr-queue` / `poll:radarr-queue`) where `record.id` is the numeric queue ID, using `String()` casting on both sides to handle the DOM-dataset (string) vs API response (number) type difference. Resolves Gitea Issue [#48](https://git.i3omb.com/Gandalf/sofarr/issues/48) (regression).
---
## [1.7.16] - 2026-05-24
### Fixed
- **Blocklist-Search Queue ID Type Mismatch** — Resolved a bug where the "Blocklist and search" action consistently returned `403 Download not found or permission denied` for all users. The server-side download lookup in `server/routes/dashboard.js` used strict equality (`===`) to compare `arrQueueId` values, but the value sent from the SPA client (read from a DOM `data-*` attribute) is always a `string`, while the value populated from the Radarr/Sonarr queue API is a `number`. Both sides are now cast to `String` before comparison, resolving the false-negative match failure. Resolves Gitea Issue [#48](https://git.i3omb.com/Gandalf/sofarr/issues/48).
---
## [1.7.15] - 2026-05-24
### Fixed
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sofarr",
"version": "1.7.15",
"version": "1.7.19",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sofarr",
"version": "1.7.15",
"version": "1.7.19",
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sofarr",
"version": "1.7.15",
"version": "1.7.19",
"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": {
+19 -1
View File
@@ -1888,6 +1888,23 @@ body {
/* ===== Mobile ===== */
@media (max-width: 768px) {
.main-tabs {
padding: 0 8px;
}
.requests-container {
padding: 8px;
}
.request-card {
gap: 8px;
padding: 10px;
}
.request-meta {
gap: 4px;
}
.app {
padding: 10px;
}
@@ -2234,6 +2251,7 @@ body {
.requests-list {
display: grid;
gap: 12px;
overflow-x: hidden;
}
.request-card {
@@ -2245,6 +2263,7 @@ body {
border: 1px solid var(--border);
border-radius: 8px;
transition: box-shadow 0.2s ease, border-color 0.2s ease;
min-width: 0;
}
.request-card:hover {
@@ -2273,7 +2292,6 @@ body {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
+1 -1
View File
@@ -132,7 +132,7 @@ function createApp({ skipRateLimits = false } = {}) {
* version:
* type: string
* description: sofarr version
* example: "1.7.14"
* example: "1.7.19"
* x-code-samples:
* - lang: curl
* label: cURL
+1 -1
View File
@@ -249,7 +249,7 @@ app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
* version:
* type: string
* description: sofarr version
* example: "1.7.14"
* example: "1.7.19"
*/
app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime(), version });
+22 -1
View File
@@ -22,7 +22,7 @@ info:
## SSE Streaming
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
version: 1.7.15
version: 1.7.19
contact:
name: sofarr
license:
@@ -176,6 +176,27 @@ components:
nullable: true
description: Tooltip text for Ombi icon ("Request" or "Search")
example: "Request"
arrLink:
type: string
nullable: true
format: uri
description: Sonarr/Radarr show/movie web UI link (admin-only)
example: "http://sonarr:8989/series/show-slug"
downloadPath:
type: string
nullable: true
description: Save path in download client (admin-only)
example: "/downloads/series/show-slug"
targetPath:
type: string
nullable: true
description: Target path in library (admin-only)
example: "/tv/show-slug"
arrInstanceKey:
type: string
nullable: true
description: Sonarr/Radarr instance API key (admin-only)
example: "api-key-here"
DashboardPayload:
type: object
+52 -10
View File
@@ -51,17 +51,43 @@ function readCacheSnapshot() {
function buildMetadataMaps(snapshot) {
const seriesMap = new Map();
for (const r of snapshot.sonarrQueue.data.records) {
if (r.series && r.seriesId) seriesMap.set(r.seriesId, r.series);
if (r.series && r.seriesId) {
if (!r.series._instanceUrl && r._instanceUrl) {
r.series._instanceUrl = r._instanceUrl;
}
seriesMap.set(r.seriesId, r.series);
}
}
for (const r of snapshot.sonarrHistory.data.records) {
if (r.series && r.seriesId && !seriesMap.has(r.seriesId)) seriesMap.set(r.seriesId, r.series);
if (r.series && r.seriesId) {
if (!r.series._instanceUrl && r._instanceUrl) {
r.series._instanceUrl = r._instanceUrl;
}
const existing = seriesMap.get(r.seriesId);
if (!existing || (!existing._instanceUrl && r.series._instanceUrl)) {
seriesMap.set(r.seriesId, r.series);
}
}
}
const moviesMap = new Map();
for (const r of snapshot.radarrQueue.data.records) {
if (r.movie && r.movieId) moviesMap.set(r.movieId, r.movie);
if (r.movie && r.movieId) {
if (!r.movie._instanceUrl && r._instanceUrl) {
r.movie._instanceUrl = r._instanceUrl;
}
moviesMap.set(r.movieId, r.movie);
}
}
for (const r of snapshot.radarrHistory.data.records) {
if (r.movie && r.movieId && !moviesMap.has(r.movieId)) moviesMap.set(r.movieId, r.movie);
if (r.movie && r.movieId) {
if (!r.movie._instanceUrl && r._instanceUrl) {
r.movie._instanceUrl = r._instanceUrl;
}
const existing = moviesMap.get(r.movieId);
if (!existing || (!existing._instanceUrl && r.movie._instanceUrl)) {
moviesMap.set(r.movieId, r.movie);
}
}
}
const sonarrTagMap = new Map(snapshot.sonarrTagsResults.flatMap(t => t.data || []).map(t => [t.id, t.label]));
const radarrTagMap = new Map(snapshot.radarrTags.data.map(t => [t.id, t.label]));
@@ -686,17 +712,33 @@ router.post('/blocklist-search', requireAuth, async (req, res) => {
return res.status(400).json({ error: 'arrType must be sonarr or radarr' });
}
// Look up the download to verify permission
const allDownloads = await downloadClientRegistry.getAllDownloads();
const download = allDownloads.find(d => d.arrQueueId === arrQueueId && d.arrType === arrType);
// Look up the queue record directly from the *arr cache.
// downloadClientRegistry.getAllDownloads() returns raw download-client data
// (qBittorrent, SABnzbd, etc.) which never has arrQueueId set — that field
// is only populated later by DownloadMatcher during the SSE build phase.
// Instead, we verify permission by finding the record in the Sonarr/Radarr
// queue cache where record.id is the numeric queue ID.
// Cast both sides to String to handle the DOM dataset → string vs API → number mismatch.
const queueCacheKey = arrType === 'sonarr' ? 'poll:sonarr-queue' : 'poll:radarr-queue';
const queueData = cache.get(queueCacheKey) || { records: [] };
const queueRecord = (queueData.records || []).find(r => r.id != null && String(r.id) === String(arrQueueId));
if (!download) {
console.error('[Blocklist] Download not found:', { arrQueueId, arrType });
if (!queueRecord) {
console.error('[Blocklist] Download not found in arr queue cache:', { arrQueueId, arrType });
return res.status(403).json({ error: 'Download not found or permission denied' });
}
// Build a minimal download-like object for canBlocklist eligibility check.
// Includes importIssues so non-admins can blocklist stalled/import-pending items.
const importIssues = require('../services/DownloadAssembler').getImportIssues(queueRecord);
const downloadForCheck = {
importIssues: importIssues || [],
arrQueueId: queueRecord.id,
arrType
};
// Check if user can blocklist this download
if (!canBlocklist(download, user.isAdmin)) {
if (!canBlocklist(downloadForCheck, user.isAdmin)) {
console.log('[Blocklist] Permission denied:', { user: user.name, isAdmin: user.isAdmin, arrQueueId, arrType });
return res.status(403).json({ error: 'Permission denied: admin or qualifying conditions required' });
}
+12
View File
@@ -215,6 +215,9 @@ async function matchSabSlots(slots, context) {
if (isAdmin) {
dlObj.downloadPath = slot.storage || null;
dlObj.targetPath = series.path || null;
if (series && !series._instanceUrl && sonarrMatch._instanceUrl) {
series._instanceUrl = sonarrMatch._instanceUrl;
}
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
dlObj.arrInstanceKey = sonarrMatch._instanceKey || null;
}
@@ -269,6 +272,9 @@ async function matchSabSlots(slots, context) {
if (isAdmin) {
dlObj.downloadPath = slot.storage || null;
dlObj.targetPath = movie.path || null;
if (movie && !movie._instanceUrl && radarrMatch._instanceUrl) {
movie._instanceUrl = radarrMatch._instanceUrl;
}
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
dlObj.arrInstanceKey = radarrMatch._instanceKey || null;
}
@@ -459,6 +465,9 @@ async function matchTorrents(torrents, context) {
if (isAdmin) {
download.downloadPath = download.savePath || null;
download.targetPath = series.path || null;
if (series && !series._instanceUrl && sonarrMatch._instanceUrl) {
series._instanceUrl = sonarrMatch._instanceUrl;
}
download.arrLink = DownloadAssembler.getSonarrLink(series);
download.arrInstanceKey = sonarrMatch._instanceKey || null;
}
@@ -505,6 +514,9 @@ async function matchTorrents(torrents, context) {
if (isAdmin) {
download.downloadPath = download.savePath || null;
download.targetPath = movie.path || null;
if (movie && !movie._instanceUrl && radarrMatch._instanceUrl) {
movie._instanceUrl = radarrMatch._instanceUrl;
}
download.arrLink = DownloadAssembler.getRadarrLink(movie);
download.arrInstanceKey = radarrMatch._instanceKey || null;
}
+67 -49
View File
@@ -225,7 +225,7 @@ function invalidatePollCache() {
'poll:sab-queue', 'poll:sab-history',
'poll:sonarr-queue', 'poll:sonarr-history', 'poll:sonarr-tags',
'poll:radarr-queue', 'poll:radarr-history', 'poll:radarr-tags',
'poll:qbittorrent'
'poll:qbittorrent', 'poll:ombi-requests'
];
for (const k of keys) cache.invalidate(k);
}
@@ -349,6 +349,7 @@ describe('GET /api/dashboard/user-downloads', () => {
expect(dl.arrQueueId).toBe(1002);
expect(dl.arrType).toBe('sonarr');
expect(dl.arrInstanceUrl).toBe(SONARR_BASE);
expect(dl.arrLink).toBe(SONARR_BASE + '/series/admin-show');
expect(dl.downloadPath).toBeDefined();
});
@@ -749,12 +750,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf } = await loginAs(app);
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
// Mock getAllDownloads to return a download that doesn't qualify for blocklist
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
{ arrQueueId: 1, arrType: 'sonarr', importIssues: [], qbittorrent: null }
]);
// Seed cache: queue record exists but has no import issues (non-admin cannot blocklist)
cache.set('poll:sonarr-queue', { records: [{
id: 1,
title: 'My.Show.S01E01.720p',
trackedDownloadState: 'downloading',
trackedDownloadStatus: 'ok'
}] }, CACHE_TTL);
const res = await request(app)
.post('/api/dashboard/blocklist-search')
@@ -763,18 +766,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'key', arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/permission denied/i);
mockGetAllDownloads.mockRestore();
});
it('returns 403 for non-admin when download not found in active downloads', async () => {
it('returns 403 for non-admin when download not found in arr queue cache', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf } = await loginAs(app);
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
// Mock getAllDownloads to return empty array (download not found)
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([]);
// Cache is already seeded empty by beforeEach; no queue record with id=1 exists
const res = await request(app)
.post('/api/dashboard/blocklist-search')
.set('Cookie', [...cookies, csrfCookie].join('; '))
@@ -782,19 +781,21 @@ describe('POST /api/dashboard/blocklist-search', () => {
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/download not found/i);
mockGetAllDownloads.mockRestore();
});
it('returns 200 for non-admin with import issues (qualifying condition)', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf } = await loginAs(app);
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
// Mock getAllDownloads to return a download with import issues (qualifies for blocklist)
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
{ arrQueueId: 1, arrType: 'sonarr', importIssues: ['Import error 1'], qbittorrent: null }
]);
// Seed cache: queue record with import issues qualifies non-admin for blocklist
cache.set('poll:sonarr-queue', { records: [{
id: 1,
title: 'My.Show.S01E01.720p',
trackedDownloadState: 'importPending',
trackedDownloadStatus: 'warning',
statusMessages: [{ messages: ['Import error 1'] }]
}] }, CACHE_TTL);
// Mock Sonarr DELETE and command endpoints
nock(SONARR_BASE)
@@ -812,7 +813,6 @@ describe('POST /api/dashboard/blocklist-search', () => {
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
mockGetAllDownloads.mockRestore();
});
it('returns 400 when required fields are missing', async () => {
@@ -843,11 +843,8 @@ describe('POST /api/dashboard/blocklist-search', () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
// Mock getAllDownloads to return a matching download for admin
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
]);
// Seed the Sonarr queue cache so the permission lookup finds the record
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
nock(SONARR_BASE)
.delete('/api/v3/queue/1001')
@@ -864,18 +861,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
mockGetAllDownloads.mockRestore();
});
it('calls Radarr DELETE+command and returns ok:true', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
// Mock getAllDownloads to return a matching download for admin
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
{ arrQueueId: 2001, arrType: 'radarr', importIssues: [], qbittorrent: null }
]);
// Seed the Radarr queue cache so the permission lookup finds the record
cache.set('poll:radarr-queue', { records: [RADARR_QUEUE_RECORD] }, CACHE_TTL);
nock(RADARR_BASE)
.delete('/api/v3/queue/2001')
@@ -892,18 +885,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
.send({ arrQueueId: 2001, arrType: 'radarr', arrInstanceUrl: RADARR_BASE, arrInstanceKey: 'rk', arrContentId: 99, arrContentType: 'movie' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
mockGetAllDownloads.mockRestore();
});
it('returns 502 when Sonarr DELETE request fails', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
// Mock getAllDownloads to return a matching download for admin
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
]);
// Seed the Sonarr queue cache so the permission lookup finds the record
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
nock(SONARR_BASE)
.delete('/api/v3/queue/1001')
@@ -916,17 +905,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
.set('X-CSRF-Token', csrf)
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
expect(res.status).toBe(502);
mockGetAllDownloads.mockRestore();
});
it('returns 200 OK when arrContentId is null but arrSeriesId is present (fallback SeriesSearch)', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
]);
// Seed the Sonarr queue cache so the permission lookup finds the record
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
nock(SONARR_BASE)
.delete('/api/v3/queue/1001')
@@ -943,17 +929,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrSeriesId: 42, arrContentType: 'episode' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
mockGetAllDownloads.mockRestore();
});
it('triggers EpisodeSearch with multiple episode IDs when arrContentIds is provided', async () => {
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
const downloadClientRegistry = require('../../server/utils/downloadClients');
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
]);
// Seed the Sonarr queue cache so the permission lookup finds the record
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
nock(SONARR_BASE)
.delete('/api/v3/queue/1001')
@@ -970,7 +953,42 @@ describe('POST /api/dashboard/blocklist-search', () => {
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentIds: [12, 13, 14], arrContentType: 'episode' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
mockGetAllDownloads.mockRestore();
});
it('matches download correctly when arrQueueId is sent as a string but stored as a number in queue cache (type mismatch regression)', async () => {
// Regression test for issue #48 (v2): arrQueueId from the SPA DOM dataset is always
// a string, but the queue record id from the Radarr/Sonarr API cache is a number.
// Without String() casting the === comparison fails and returns 403.
const app = createApp({ skipRateLimits: true });
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
// Seed Radarr queue with a numeric id (as Radarr API returns it)
cache.set('poll:radarr-queue', { records: [{
id: 9050001,
title: 'Project.Hail.Mary.2026.2160p',
movieId: 77,
trackedDownloadState: 'downloading',
trackedDownloadStatus: 'ok',
_instanceUrl: RADARR_BASE,
_instanceKey: 'rk'
}] }, CACHE_TTL);
nock(RADARR_BASE)
.delete('/api/v3/queue/9050001')
.query({ removeFromClient: 'true', blocklist: 'true' })
.reply(200, {});
nock(RADARR_BASE)
.post('/api/v3/command')
.reply(200, {});
const res = await request(app)
.post('/api/dashboard/blocklist-search')
.set('Cookie', [...cookies, csrfCookie].join('; '))
.set('X-CSRF-Token', csrf)
// arrQueueId sent as a STRING from the client (as the SPA DOM dataset does)
.send({ arrQueueId: '9050001', arrType: 'radarr', arrInstanceUrl: RADARR_BASE, arrInstanceKey: 'rk', arrContentId: 77, arrContentType: 'movie' });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
});