Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb68bddedb | |||
| 81d0aa82f2 | |||
| 7d7304637c | |||
| d87ad9f1c7 | |||
| ce71be29c4 | |||
| b8870ca6cf | |||
| 90837f6e3b | |||
| fbc071da09 | |||
| 7690d959b3 | |||
| 1ba9d15954 | |||
| 83c9d4d164 |
+2
-1
@@ -11,4 +11,5 @@ data/
|
|||||||
*.db-wal
|
*.db-wal
|
||||||
*.db-shm
|
*.db-shm
|
||||||
.agents/
|
.agents/
|
||||||
.windsurf/
|
.windsurf/
|
||||||
|
scratch/
|
||||||
@@ -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/).
|
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.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
|
## [1.7.15] - 2026-05-24
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.15",
|
"version": "1.7.19",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.15",
|
"version": "1.7.19",
|
||||||
"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.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",
|
"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": {
|
||||||
|
|||||||
+19
-1
@@ -1888,6 +1888,23 @@ body {
|
|||||||
|
|
||||||
/* ===== Mobile ===== */
|
/* ===== Mobile ===== */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.main-tabs {
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.requests-container {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-card {
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-meta {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
@@ -2234,6 +2251,7 @@ body {
|
|||||||
.requests-list {
|
.requests-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.request-card {
|
.request-card {
|
||||||
@@ -2245,6 +2263,7 @@ body {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
transition: box-shadow 0.2s ease, border-color 0.2s ease;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.request-card:hover {
|
.request-card:hover {
|
||||||
@@ -2273,7 +2292,6 @@ body {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -132,7 +132,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
* version:
|
* version:
|
||||||
* type: string
|
* type: string
|
||||||
* description: sofarr version
|
* description: sofarr version
|
||||||
* example: "1.7.14"
|
* example: "1.7.19"
|
||||||
* x-code-samples:
|
* x-code-samples:
|
||||||
* - lang: curl
|
* - lang: curl
|
||||||
* label: cURL
|
* label: cURL
|
||||||
|
|||||||
+1
-1
@@ -249,7 +249,7 @@ app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
|
|||||||
* version:
|
* version:
|
||||||
* type: string
|
* type: string
|
||||||
* description: sofarr version
|
* description: sofarr version
|
||||||
* example: "1.7.14"
|
* example: "1.7.19"
|
||||||
*/
|
*/
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({ status: 'ok', uptime: process.uptime(), version });
|
res.json({ status: 'ok', uptime: process.uptime(), version });
|
||||||
|
|||||||
+22
-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.15
|
version: 1.7.19
|
||||||
contact:
|
contact:
|
||||||
name: sofarr
|
name: sofarr
|
||||||
license:
|
license:
|
||||||
@@ -176,6 +176,27 @@ components:
|
|||||||
nullable: true
|
nullable: true
|
||||||
description: Tooltip text for Ombi icon ("Request" or "Search")
|
description: Tooltip text for Ombi icon ("Request" or "Search")
|
||||||
example: "Request"
|
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:
|
DashboardPayload:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
+52
-10
@@ -51,17 +51,43 @@ function readCacheSnapshot() {
|
|||||||
function buildMetadataMaps(snapshot) {
|
function buildMetadataMaps(snapshot) {
|
||||||
const seriesMap = new Map();
|
const seriesMap = new Map();
|
||||||
for (const r of snapshot.sonarrQueue.data.records) {
|
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) {
|
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();
|
const moviesMap = new Map();
|
||||||
for (const r of snapshot.radarrQueue.data.records) {
|
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) {
|
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 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]));
|
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' });
|
return res.status(400).json({ error: 'arrType must be sonarr or radarr' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up the download to verify permission
|
// Look up the queue record directly from the *arr cache.
|
||||||
const allDownloads = await downloadClientRegistry.getAllDownloads();
|
// downloadClientRegistry.getAllDownloads() returns raw download-client data
|
||||||
const download = allDownloads.find(d => d.arrQueueId === arrQueueId && d.arrType === arrType);
|
// (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) {
|
if (!queueRecord) {
|
||||||
console.error('[Blocklist] Download not found:', { arrQueueId, arrType });
|
console.error('[Blocklist] Download not found in arr queue cache:', { arrQueueId, arrType });
|
||||||
return res.status(403).json({ error: 'Download not found or permission denied' });
|
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
|
// 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 });
|
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' });
|
return res.status(403).json({ error: 'Permission denied: admin or qualifying conditions required' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,6 +215,9 @@ async function matchSabSlots(slots, context) {
|
|||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
dlObj.downloadPath = slot.storage || null;
|
dlObj.downloadPath = slot.storage || null;
|
||||||
dlObj.targetPath = series.path || null;
|
dlObj.targetPath = series.path || null;
|
||||||
|
if (series && !series._instanceUrl && sonarrMatch._instanceUrl) {
|
||||||
|
series._instanceUrl = sonarrMatch._instanceUrl;
|
||||||
|
}
|
||||||
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
|
dlObj.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||||
dlObj.arrInstanceKey = sonarrMatch._instanceKey || null;
|
dlObj.arrInstanceKey = sonarrMatch._instanceKey || null;
|
||||||
}
|
}
|
||||||
@@ -269,6 +272,9 @@ async function matchSabSlots(slots, context) {
|
|||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
dlObj.downloadPath = slot.storage || null;
|
dlObj.downloadPath = slot.storage || null;
|
||||||
dlObj.targetPath = movie.path || null;
|
dlObj.targetPath = movie.path || null;
|
||||||
|
if (movie && !movie._instanceUrl && radarrMatch._instanceUrl) {
|
||||||
|
movie._instanceUrl = radarrMatch._instanceUrl;
|
||||||
|
}
|
||||||
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
|
dlObj.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||||
dlObj.arrInstanceKey = radarrMatch._instanceKey || null;
|
dlObj.arrInstanceKey = radarrMatch._instanceKey || null;
|
||||||
}
|
}
|
||||||
@@ -459,6 +465,9 @@ async function matchTorrents(torrents, context) {
|
|||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
download.downloadPath = download.savePath || null;
|
download.downloadPath = download.savePath || null;
|
||||||
download.targetPath = series.path || null;
|
download.targetPath = series.path || null;
|
||||||
|
if (series && !series._instanceUrl && sonarrMatch._instanceUrl) {
|
||||||
|
series._instanceUrl = sonarrMatch._instanceUrl;
|
||||||
|
}
|
||||||
download.arrLink = DownloadAssembler.getSonarrLink(series);
|
download.arrLink = DownloadAssembler.getSonarrLink(series);
|
||||||
download.arrInstanceKey = sonarrMatch._instanceKey || null;
|
download.arrInstanceKey = sonarrMatch._instanceKey || null;
|
||||||
}
|
}
|
||||||
@@ -505,6 +514,9 @@ async function matchTorrents(torrents, context) {
|
|||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
download.downloadPath = download.savePath || null;
|
download.downloadPath = download.savePath || null;
|
||||||
download.targetPath = movie.path || null;
|
download.targetPath = movie.path || null;
|
||||||
|
if (movie && !movie._instanceUrl && radarrMatch._instanceUrl) {
|
||||||
|
movie._instanceUrl = radarrMatch._instanceUrl;
|
||||||
|
}
|
||||||
download.arrLink = DownloadAssembler.getRadarrLink(movie);
|
download.arrLink = DownloadAssembler.getRadarrLink(movie);
|
||||||
download.arrInstanceKey = radarrMatch._instanceKey || null;
|
download.arrInstanceKey = radarrMatch._instanceKey || null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ function invalidatePollCache() {
|
|||||||
'poll:sab-queue', 'poll:sab-history',
|
'poll:sab-queue', 'poll:sab-history',
|
||||||
'poll:sonarr-queue', 'poll:sonarr-history', 'poll:sonarr-tags',
|
'poll:sonarr-queue', 'poll:sonarr-history', 'poll:sonarr-tags',
|
||||||
'poll:radarr-queue', 'poll:radarr-history', 'poll:radarr-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);
|
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.arrQueueId).toBe(1002);
|
||||||
expect(dl.arrType).toBe('sonarr');
|
expect(dl.arrType).toBe('sonarr');
|
||||||
expect(dl.arrInstanceUrl).toBe(SONARR_BASE);
|
expect(dl.arrInstanceUrl).toBe(SONARR_BASE);
|
||||||
|
expect(dl.arrLink).toBe(SONARR_BASE + '/series/admin-show');
|
||||||
expect(dl.downloadPath).toBeDefined();
|
expect(dl.downloadPath).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -749,12 +750,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
|||||||
const app = createApp({ skipRateLimits: true });
|
const app = createApp({ skipRateLimits: true });
|
||||||
const { cookies, csrf } = await loginAs(app);
|
const { cookies, csrf } = await loginAs(app);
|
||||||
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
||||||
|
|
||||||
// Mock getAllDownloads to return a download that doesn't qualify for blocklist
|
// Seed cache: queue record exists but has no import issues (non-admin cannot blocklist)
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
cache.set('poll:sonarr-queue', { records: [{
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
id: 1,
|
||||||
{ arrQueueId: 1, arrType: 'sonarr', importIssues: [], qbittorrent: null }
|
title: 'My.Show.S01E01.720p',
|
||||||
]);
|
trackedDownloadState: 'downloading',
|
||||||
|
trackedDownloadStatus: 'ok'
|
||||||
|
}] }, CACHE_TTL);
|
||||||
|
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post('/api/dashboard/blocklist-search')
|
.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' });
|
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'key', arrContentId: 501, arrContentType: 'episode' });
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
expect(res.body.error).toMatch(/permission denied/i);
|
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 app = createApp({ skipRateLimits: true });
|
||||||
const { cookies, csrf } = await loginAs(app);
|
const { cookies, csrf } = await loginAs(app);
|
||||||
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
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)
|
const res = await request(app)
|
||||||
.post('/api/dashboard/blocklist-search')
|
.post('/api/dashboard/blocklist-search')
|
||||||
.set('Cookie', [...cookies, csrfCookie].join('; '))
|
.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' });
|
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrContentId: 501, arrContentType: 'episode' });
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
expect(res.body.error).toMatch(/download not found/i);
|
expect(res.body.error).toMatch(/download not found/i);
|
||||||
mockGetAllDownloads.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 200 for non-admin with import issues (qualifying condition)', async () => {
|
it('returns 200 for non-admin with import issues (qualifying condition)', async () => {
|
||||||
const app = createApp({ skipRateLimits: true });
|
const app = createApp({ skipRateLimits: true });
|
||||||
const { cookies, csrf } = await loginAs(app);
|
const { cookies, csrf } = await loginAs(app);
|
||||||
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
const csrfCookie = cookies.find(c => c.startsWith('csrf_token='));
|
||||||
|
|
||||||
// Mock getAllDownloads to return a download with import issues (qualifies for blocklist)
|
// Seed cache: queue record with import issues — qualifies non-admin for blocklist
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
cache.set('poll:sonarr-queue', { records: [{
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
id: 1,
|
||||||
{ arrQueueId: 1, arrType: 'sonarr', importIssues: ['Import error 1'], qbittorrent: null }
|
title: 'My.Show.S01E01.720p',
|
||||||
]);
|
trackedDownloadState: 'importPending',
|
||||||
|
trackedDownloadStatus: 'warning',
|
||||||
|
statusMessages: [{ messages: ['Import error 1'] }]
|
||||||
|
}] }, CACHE_TTL);
|
||||||
|
|
||||||
// Mock Sonarr DELETE and command endpoints
|
// Mock Sonarr DELETE and command endpoints
|
||||||
nock(SONARR_BASE)
|
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' });
|
.send({ arrQueueId: 1, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrContentId: 501, arrContentType: 'episode' });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.ok).toBe(true);
|
expect(res.body.ok).toBe(true);
|
||||||
mockGetAllDownloads.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 400 when required fields are missing', async () => {
|
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 app = createApp({ skipRateLimits: true });
|
||||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||||
|
|
||||||
// Mock getAllDownloads to return a matching download for admin
|
// Seed the Sonarr queue cache so the permission lookup finds the record
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
|
||||||
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
|
|
||||||
]);
|
|
||||||
|
|
||||||
nock(SONARR_BASE)
|
nock(SONARR_BASE)
|
||||||
.delete('/api/v3/queue/1001')
|
.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' });
|
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.ok).toBe(true);
|
expect(res.body.ok).toBe(true);
|
||||||
mockGetAllDownloads.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls Radarr DELETE+command and returns ok:true', async () => {
|
it('calls Radarr DELETE+command and returns ok:true', async () => {
|
||||||
const app = createApp({ skipRateLimits: true });
|
const app = createApp({ skipRateLimits: true });
|
||||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||||
|
|
||||||
// Mock getAllDownloads to return a matching download for admin
|
// Seed the Radarr queue cache so the permission lookup finds the record
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
cache.set('poll:radarr-queue', { records: [RADARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
|
||||||
{ arrQueueId: 2001, arrType: 'radarr', importIssues: [], qbittorrent: null }
|
|
||||||
]);
|
|
||||||
|
|
||||||
nock(RADARR_BASE)
|
nock(RADARR_BASE)
|
||||||
.delete('/api/v3/queue/2001')
|
.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' });
|
.send({ arrQueueId: 2001, arrType: 'radarr', arrInstanceUrl: RADARR_BASE, arrInstanceKey: 'rk', arrContentId: 99, arrContentType: 'movie' });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.ok).toBe(true);
|
expect(res.body.ok).toBe(true);
|
||||||
mockGetAllDownloads.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 502 when Sonarr DELETE request fails', async () => {
|
it('returns 502 when Sonarr DELETE request fails', async () => {
|
||||||
const app = createApp({ skipRateLimits: true });
|
const app = createApp({ skipRateLimits: true });
|
||||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||||
|
|
||||||
// Mock getAllDownloads to return a matching download for admin
|
// Seed the Sonarr queue cache so the permission lookup finds the record
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
|
||||||
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
|
|
||||||
]);
|
|
||||||
|
|
||||||
nock(SONARR_BASE)
|
nock(SONARR_BASE)
|
||||||
.delete('/api/v3/queue/1001')
|
.delete('/api/v3/queue/1001')
|
||||||
@@ -916,17 +905,14 @@ describe('POST /api/dashboard/blocklist-search', () => {
|
|||||||
.set('X-CSRF-Token', csrf)
|
.set('X-CSRF-Token', csrf)
|
||||||
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
|
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentId: 501, arrContentType: 'episode' });
|
||||||
expect(res.status).toBe(502);
|
expect(res.status).toBe(502);
|
||||||
mockGetAllDownloads.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 200 OK when arrContentId is null but arrSeriesId is present (fallback SeriesSearch)', async () => {
|
it('returns 200 OK when arrContentId is null but arrSeriesId is present (fallback SeriesSearch)', async () => {
|
||||||
const app = createApp({ skipRateLimits: true });
|
const app = createApp({ skipRateLimits: true });
|
||||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||||
|
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
// Seed the Sonarr queue cache so the permission lookup finds the record
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
|
|
||||||
]);
|
|
||||||
|
|
||||||
nock(SONARR_BASE)
|
nock(SONARR_BASE)
|
||||||
.delete('/api/v3/queue/1001')
|
.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' });
|
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrSeriesId: 42, arrContentType: 'episode' });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.ok).toBe(true);
|
expect(res.body.ok).toBe(true);
|
||||||
mockGetAllDownloads.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('triggers EpisodeSearch with multiple episode IDs when arrContentIds is provided', async () => {
|
it('triggers EpisodeSearch with multiple episode IDs when arrContentIds is provided', async () => {
|
||||||
const app = createApp({ skipRateLimits: true });
|
const app = createApp({ skipRateLimits: true });
|
||||||
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
const { cookies, csrf, csrfCookie } = await getAuthHeaders(app);
|
||||||
|
|
||||||
const downloadClientRegistry = require('../../server/utils/downloadClients');
|
// Seed the Sonarr queue cache so the permission lookup finds the record
|
||||||
const mockGetAllDownloads = vi.spyOn(downloadClientRegistry, 'getAllDownloads').mockResolvedValue([
|
cache.set('poll:sonarr-queue', { records: [SONARR_QUEUE_RECORD] }, CACHE_TTL);
|
||||||
{ arrQueueId: 1001, arrType: 'sonarr', importIssues: [], qbittorrent: null }
|
|
||||||
]);
|
|
||||||
|
|
||||||
nock(SONARR_BASE)
|
nock(SONARR_BASE)
|
||||||
.delete('/api/v3/queue/1001')
|
.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' });
|
.send({ arrQueueId: 1001, arrType: 'sonarr', arrInstanceUrl: SONARR_BASE, arrInstanceKey: 'sk', arrContentIds: [12, 13, 14], arrContentType: 'episode' });
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(res.body.ok).toBe(true);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user