Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c7dcf55b0 | |||
| afc940aba7 | |||
| 5488969387 |
@@ -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/).
|
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.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
|
## [1.7.10] - 2026-05-24
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ export async function handleBlocklistSearch(download) {
|
|||||||
arrInstanceUrl: download.arrInstanceUrl,
|
arrInstanceUrl: download.arrInstanceUrl,
|
||||||
arrInstanceKey: download.arrInstanceKey,
|
arrInstanceKey: download.arrInstanceKey,
|
||||||
arrContentId: download.arrContentId,
|
arrContentId: download.arrContentId,
|
||||||
|
arrContentIds: download.arrContentIds,
|
||||||
|
arrSeriesId: download.arrSeriesId,
|
||||||
arrContentType: download.arrContentType
|
arrContentType: download.arrContentType
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -272,6 +272,8 @@ export async function handleBlocklistSearchClick(btn, download) {
|
|||||||
arrInstanceUrl: download.arrInstanceUrl,
|
arrInstanceUrl: download.arrInstanceUrl,
|
||||||
arrInstanceKey: download.arrInstanceKey,
|
arrInstanceKey: download.arrInstanceKey,
|
||||||
arrContentId: download.arrContentId,
|
arrContentId: download.arrContentId,
|
||||||
|
arrContentIds: download.arrContentIds,
|
||||||
|
arrSeriesId: download.arrSeriesId,
|
||||||
arrContentType: download.arrContentType,
|
arrContentType: download.arrContentType,
|
||||||
isAdmin: state.isAdmin,
|
isAdmin: state.isAdmin,
|
||||||
canBlocklist: download.canBlocklist
|
canBlocklist: download.canBlocklist
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.10",
|
"version": "1.7.11",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.10",
|
"version": "1.7.11",
|
||||||
"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.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",
|
"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": {
|
||||||
|
|||||||
+10
-1
@@ -276,7 +276,6 @@ components:
|
|||||||
- arrQueueId
|
- arrQueueId
|
||||||
- arrType
|
- arrType
|
||||||
- arrInstanceUrl
|
- arrInstanceUrl
|
||||||
- arrContentId
|
|
||||||
- arrContentType
|
- arrContentType
|
||||||
properties:
|
properties:
|
||||||
arrQueueId:
|
arrQueueId:
|
||||||
@@ -301,6 +300,16 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
description: episodeId (Sonarr) or movieId (Radarr)
|
description: episodeId (Sonarr) or movieId (Radarr)
|
||||||
example: 456
|
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:
|
arrContentType:
|
||||||
type: string
|
type: string
|
||||||
enum: [episode, movie]
|
enum: [episode, movie]
|
||||||
|
|||||||
@@ -676,10 +676,10 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
router.post('/blocklist-search', requireAuth, async (req, res) => {
|
router.post('/blocklist-search', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = req.user;
|
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) {
|
if (!arrQueueId || !arrType || !arrInstanceUrl || !arrContentType) {
|
||||||
console.error('[Blocklist] Missing required fields:', { arrQueueId, arrType, arrInstanceUrl, hasKey: !!arrInstanceKey, arrContentId, arrContentType });
|
console.error('[Blocklist] Missing required fields:', { arrQueueId, arrType, arrInstanceUrl, hasKey: !!arrInstanceKey, arrContentType });
|
||||||
return res.status(400).json({ error: 'Missing required fields' });
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
}
|
}
|
||||||
if (arrType !== 'sonarr' && arrType !== 'radarr') {
|
if (arrType !== 'sonarr' && arrType !== 'radarr') {
|
||||||
@@ -724,8 +724,14 @@ router.post('/blocklist-search', requireAuth, async (req, res) => {
|
|||||||
// Step 2: Trigger a new automatic search
|
// Step 2: Trigger a new automatic search
|
||||||
let commandBody;
|
let commandBody;
|
||||||
if (arrType === 'sonarr' && arrContentType === 'episode') {
|
if (arrType === 'sonarr' && arrContentType === 'episode') {
|
||||||
commandBody = { name: 'EpisodeSearch', episodeIds: [arrContentId] };
|
if (arrContentId) {
|
||||||
} else if (arrType === 'radarr' && arrContentType === 'movie') {
|
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] };
|
commandBody = { name: 'MoviesSearch', movieIds: [arrContentId] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,7 +743,7 @@ router.post('/blocklist-search', requireAuth, async (req, res) => {
|
|||||||
const { pollAllServices } = require('../utils/poller');
|
const { pollAllServices } = require('../utils/poller');
|
||||||
pollAllServices().catch(() => {});
|
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 });
|
res.json({ ok: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Dashboard] blocklist-search error:', sanitizeError(err));
|
console.error('[Dashboard] blocklist-search error:', sanitizeError(err));
|
||||||
|
|||||||
@@ -209,6 +209,8 @@ async function matchSabSlots(slots, context) {
|
|||||||
dlObj.arrType = 'sonarr';
|
dlObj.arrType = 'sonarr';
|
||||||
dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||||
dlObj.arrContentId = sonarrMatch.episodeId || null;
|
dlObj.arrContentId = sonarrMatch.episodeId || null;
|
||||||
|
dlObj.arrContentIds = sonarrMatch.episodeIds || null;
|
||||||
|
dlObj.arrSeriesId = sonarrMatch.seriesId || null;
|
||||||
dlObj.arrContentType = 'episode';
|
dlObj.arrContentType = 'episode';
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
dlObj.downloadPath = slot.storage || null;
|
dlObj.downloadPath = slot.storage || null;
|
||||||
@@ -451,6 +453,8 @@ async function matchTorrents(torrents, context) {
|
|||||||
download.arrType = 'sonarr';
|
download.arrType = 'sonarr';
|
||||||
download.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
download.arrInstanceUrl = sonarrMatch._instanceUrl || null;
|
||||||
download.arrContentId = sonarrMatch.episodeId || null;
|
download.arrContentId = sonarrMatch.episodeId || null;
|
||||||
|
download.arrContentIds = sonarrMatch.episodeIds || null;
|
||||||
|
download.arrSeriesId = sonarrMatch.seriesId || null;
|
||||||
download.arrContentType = 'episode';
|
download.arrContentType = 'episode';
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
download.downloadPath = download.savePath || null;
|
download.downloadPath = download.savePath || null;
|
||||||
|
|||||||
@@ -918,6 +918,60 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
|||||||
expect(res.status).toBe(502);
|
expect(res.status).toBe(502);
|
||||||
mockGetAllDownloads.mockRestore();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user