From 54889693878efa9bb801e6e4e11a43ce63080ee2 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sun, 24 May 2026 10:42:54 +0100 Subject: [PATCH 1/2] fix: resolve blocklist & search failures on Sonarr season packs and multi-episode releases --- client/src/api.js | 2 ++ client/src/ui/downloads.js | 2 ++ server/openapi.yaml | 11 +++++- server/routes/dashboard.js | 18 ++++++---- server/services/DownloadMatcher.js | 4 +++ tests/integration/dashboard.test.js | 54 +++++++++++++++++++++++++++++ 6 files changed, 84 insertions(+), 7 deletions(-) diff --git a/client/src/api.js b/client/src/api.js index 22a0e22..449f87d 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -97,6 +97,8 @@ export async function handleBlocklistSearch(download) { arrInstanceUrl: download.arrInstanceUrl, arrInstanceKey: download.arrInstanceKey, arrContentId: download.arrContentId, + arrContentIds: download.arrContentIds, + arrSeriesId: download.arrSeriesId, arrContentType: download.arrContentType }) }); diff --git a/client/src/ui/downloads.js b/client/src/ui/downloads.js index 905d4bb..6100b0a 100644 --- a/client/src/ui/downloads.js +++ b/client/src/ui/downloads.js @@ -272,6 +272,8 @@ export async function handleBlocklistSearchClick(btn, download) { arrInstanceUrl: download.arrInstanceUrl, arrInstanceKey: download.arrInstanceKey, arrContentId: download.arrContentId, + arrContentIds: download.arrContentIds, + arrSeriesId: download.arrSeriesId, arrContentType: download.arrContentType, isAdmin: state.isAdmin, canBlocklist: download.canBlocklist diff --git a/server/openapi.yaml b/server/openapi.yaml index cc9c8b7..bdb4f30 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -276,7 +276,6 @@ components: - arrQueueId - arrType - arrInstanceUrl - - arrContentId - arrContentType properties: arrQueueId: @@ -301,6 +300,16 @@ components: type: integer description: episodeId (Sonarr) or movieId (Radarr) example: 456 + arrContentIds: + type: array + items: + type: integer + description: Array of episodeIds for multi-episode packs (Sonarr) + example: [456, 457] + arrSeriesId: + type: integer + description: seriesId for fallback automatic series search (Sonarr) + example: 789 arrContentType: type: string enum: [episode, movie] diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js index 6e430b2..25bb7ff 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -676,10 +676,10 @@ router.get('/stream', requireAuth, async (req, res) => { router.post('/blocklist-search', requireAuth, async (req, res) => { try { const user = req.user; - const { arrQueueId, arrType, arrInstanceUrl, arrInstanceKey, arrContentId, arrContentType } = req.body; + const { arrQueueId, arrType, arrInstanceUrl, arrInstanceKey, arrContentId, arrContentIds, arrSeriesId, arrContentType } = req.body; - if (!arrQueueId || !arrType || !arrInstanceUrl || !arrContentId || !arrContentType) { - console.error('[Blocklist] Missing required fields:', { arrQueueId, arrType, arrInstanceUrl, hasKey: !!arrInstanceKey, arrContentId, arrContentType }); + if (!arrQueueId || !arrType || !arrInstanceUrl || !arrContentType) { + console.error('[Blocklist] Missing required fields:', { arrQueueId, arrType, arrInstanceUrl, hasKey: !!arrInstanceKey, arrContentType }); return res.status(400).json({ error: 'Missing required fields' }); } if (arrType !== 'sonarr' && arrType !== 'radarr') { @@ -724,8 +724,14 @@ router.post('/blocklist-search', requireAuth, async (req, res) => { // Step 2: Trigger a new automatic search let commandBody; if (arrType === 'sonarr' && arrContentType === 'episode') { - commandBody = { name: 'EpisodeSearch', episodeIds: [arrContentId] }; - } else if (arrType === 'radarr' && arrContentType === 'movie') { + if (arrContentId) { + commandBody = { name: 'EpisodeSearch', episodeIds: [arrContentId] }; + } else if (Array.isArray(arrContentIds) && arrContentIds.length > 0) { + commandBody = { name: 'EpisodeSearch', episodeIds: arrContentIds.map(Number) }; + } else if (arrSeriesId) { + commandBody = { name: 'SeriesSearch', seriesId: Number(arrSeriesId) }; + } + } else if (arrType === 'radarr' && arrContentType === 'movie' && arrContentId) { commandBody = { name: 'MoviesSearch', movieIds: [arrContentId] }; } @@ -737,7 +743,7 @@ router.post('/blocklist-search', requireAuth, async (req, res) => { const { pollAllServices } = require('../utils/poller'); pollAllServices().catch(() => {}); - console.log(`[Dashboard] Blocklist+search: ${arrType} queueId=${arrQueueId} contentId=${arrContentId} by ${user.name}`); + console.log(`[Dashboard] Blocklist+search: ${arrType} queueId=${arrQueueId} contentId=${arrContentId || 'none'} by ${user.name}`); res.json({ ok: true }); } catch (err) { console.error('[Dashboard] blocklist-search error:', sanitizeError(err)); diff --git a/server/services/DownloadMatcher.js b/server/services/DownloadMatcher.js index 44d2d37..4baf005 100644 --- a/server/services/DownloadMatcher.js +++ b/server/services/DownloadMatcher.js @@ -209,6 +209,8 @@ async function matchSabSlots(slots, context) { dlObj.arrType = 'sonarr'; dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null; dlObj.arrContentId = sonarrMatch.episodeId || null; + dlObj.arrContentIds = sonarrMatch.episodeIds || null; + dlObj.arrSeriesId = sonarrMatch.seriesId || null; dlObj.arrContentType = 'episode'; if (isAdmin) { dlObj.downloadPath = slot.storage || null; @@ -451,6 +453,8 @@ async function matchTorrents(torrents, context) { download.arrType = 'sonarr'; download.arrInstanceUrl = sonarrMatch._instanceUrl || null; download.arrContentId = sonarrMatch.episodeId || null; + download.arrContentIds = sonarrMatch.episodeIds || null; + download.arrSeriesId = sonarrMatch.seriesId || null; download.arrContentType = 'episode'; if (isAdmin) { download.downloadPath = download.savePath || null; diff --git a/tests/integration/dashboard.test.js b/tests/integration/dashboard.test.js index 4f4c16f..10a38fe 100644 --- a/tests/integration/dashboard.test.js +++ b/tests/integration/dashboard.test.js @@ -918,6 +918,60 @@ describe('POST /api/dashboard/blocklist-search', () => { 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 } + ]); + + nock(SONARR_BASE) + .delete('/api/v3/queue/1001') + .query({ removeFromClient: 'true', blocklist: 'true' }) + .reply(200, {}); + nock(SONARR_BASE) + .post('/api/v3/command', { name: 'SeriesSearch', seriesId: 42 }) + .reply(200, {}); + + const res = await request(app) + .post('/api/dashboard/blocklist-search') + .set('Cookie', [...cookies, csrfCookie].join('; ')) + .set('X-CSRF-Token', csrf) + .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 } + ]); + + nock(SONARR_BASE) + .delete('/api/v3/queue/1001') + .query({ removeFromClient: 'true', blocklist: 'true' }) + .reply(200, {}); + nock(SONARR_BASE) + .post('/api/v3/command', { name: 'EpisodeSearch', episodeIds: [12, 13, 14] }) + .reply(200, {}); + + const res = await request(app) + .post('/api/dashboard/blocklist-search') + .set('Cookie', [...cookies, csrfCookie].join('; ')) + .set('X-CSRF-Token', csrf) + .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(); + }); }); // --------------------------------------------------------------------------- From afc940aba7f94d1eb6e4ffde2a5c7f9c08abbbfa Mon Sep 17 00:00:00 2001 From: Gronod Date: Sun, 24 May 2026 10:48:52 +0100 Subject: [PATCH 2/2] chore: bump version to 1.7.11 and update CHANGELOG --- CHANGELOG.md | 10 ++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 379421c..2eef271 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ 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.11] - 2026-05-24 + +### Fixed + +- **Sonarr full-season and multi-episode blocklist-search failures** — Fixed a bug where clicking the "Blocklist & Search" button on television downloads representing full-season packs or multi-episode releases failed with a `400 Bad Request` error. We relaxed the API validation on `POST /api/dashboard/blocklist-search` to make `arrContentId` optional. In `DownloadMatcher.js`, we exposed `arrContentIds` (holding all episode IDs associated with the matched release) and `arrSeriesId` (holding the series ID). +- **Enhanced blocklist re-search commands** — The backend now triggers multi-episode `EpisodeSearch` commands using the `episodeIds` array when blocklisting multi-episode releases, and falls back to a series-wide `SeriesSearch` command using the `seriesId` when no specific episode IDs can be resolved. +- **OpenAPI Schema and Test Coverage** — Updated the OpenAPI specifications (`server/openapi.yaml`) to reflect optional/new parameters on `BlocklistSearchRequest`, and implemented new integration tests in `tests/integration/dashboard.test.js` to safeguard fallback and multi-episode search commands. + +--- + ## [1.7.10] - 2026-05-24 ### Fixed diff --git a/package-lock.json b/package-lock.json index 800ad9e..3318cb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sofarr", - "version": "1.7.10", + "version": "1.7.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sofarr", - "version": "1.7.10", + "version": "1.7.11", "license": "MIT", "dependencies": { "axios": "^1.6.0", diff --git a/package.json b/package.json index 6d0eec4..320de8b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sofarr", - "version": "1.7.10", + "version": "1.7.11", "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": {