From 0341540751c3257a503ea816325e5ba9acb6aba7 Mon Sep 17 00:00:00 2001 From: Gronod Date: Sun, 17 May 2026 23:43:37 +0100 Subject: [PATCH 1/6] feat: show blocklist & search button on all admin downloads (not just import-pending) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove importIssues condition from arr action fields threading in /user-downloads route (all 4 blocks: SAB+Sonarr, SAB+Radarr, qBit+Sonarr, qBit+Radarr) - Remove importIssues condition from arr action fields threading in SSE /stream route (all 4 blocks) - Move blocklist button rendering outside importIssues condition in frontend — now shows for all admin downloads with arrQueueId --- public/app.js | 16 +++++----- server/routes/dashboard.js | 64 +++++++++++++++++--------------------- 2 files changed, 36 insertions(+), 44 deletions(-) diff --git a/public/app.js b/public/app.js index c7ea835..cf28451 100644 --- a/public/app.js +++ b/public/app.js @@ -554,15 +554,15 @@ function createDownloadCard(download) { issueBadge.textContent = 'Import Pending'; issueBadge.setAttribute('data-tooltip', download.importIssues.join('\n')); header.appendChild(issueBadge); + } - if (isAdmin && download.arrQueueId) { - const blBtn = document.createElement('button'); - blBtn.className = 'blocklist-search-btn'; - blBtn.textContent = '⛔ Blocklist & Search'; - blBtn.title = 'Remove this release from the download client, add it to the blocklist, and trigger a new automatic search'; - blBtn.addEventListener('click', () => handleBlocklistSearch(blBtn, download)); - header.appendChild(blBtn); - } + if (isAdmin && download.arrQueueId) { + const blBtn = document.createElement('button'); + blBtn.className = 'blocklist-search-btn'; + blBtn.textContent = '⛔ Blocklist & Search'; + blBtn.title = 'Remove this release from the download client, add it to the blocklist, and trigger a new automatic search'; + blBtn.addEventListener('click', () => handleBlocklistSearch(blBtn, download)); + header.appendChild(blBtn); } const title = document.createElement('h3'); diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js index b768458..321ae45 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -321,14 +321,12 @@ router.get('/user-downloads', requireAuth, async (req, res) => { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); - if (issues) { - dlObj.arrQueueId = sonarrMatch.id; - dlObj.arrType = 'sonarr'; - dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null; - dlObj.arrInstanceKey = sonarrMatch._instanceKey || null; - dlObj.arrContentId = sonarrMatch.episodeId || null; - dlObj.arrContentType = 'episode'; - } + dlObj.arrQueueId = sonarrMatch.id; + dlObj.arrType = 'sonarr'; + dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null; + dlObj.arrInstanceKey = sonarrMatch._instanceKey || null; + dlObj.arrContentId = sonarrMatch.episodeId || null; + dlObj.arrContentType = 'episode'; } userDownloads.push(dlObj); } @@ -371,14 +369,12 @@ router.get('/user-downloads', requireAuth, async (req, res) => { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = getRadarrLink(movie); - if (issues) { - dlObj.arrQueueId = radarrMatch.id; - dlObj.arrType = 'radarr'; - dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null; - dlObj.arrInstanceKey = radarrMatch._instanceKey || null; - dlObj.arrContentId = radarrMatch.movieId || null; - dlObj.arrContentType = 'movie'; - } + dlObj.arrQueueId = radarrMatch.id; + dlObj.arrType = 'radarr'; + dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null; + dlObj.arrInstanceKey = radarrMatch._instanceKey || null; + dlObj.arrContentId = radarrMatch.movieId || null; + dlObj.arrContentType = 'movie'; } userDownloads.push(dlObj); } @@ -536,14 +532,12 @@ router.get('/user-downloads', requireAuth, async (req, res) => { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); - if (sonarrIssues) { - download.arrQueueId = sonarrMatch.id; - download.arrType = 'sonarr'; - download.arrInstanceUrl = sonarrMatch._instanceUrl || null; - download.arrInstanceKey = sonarrMatch._instanceKey || null; - download.arrContentId = sonarrMatch.episodeId || null; - download.arrContentType = 'episode'; - } + download.arrQueueId = sonarrMatch.id; + download.arrType = 'sonarr'; + download.arrInstanceUrl = sonarrMatch._instanceUrl || null; + download.arrInstanceKey = sonarrMatch._instanceKey || null; + download.arrContentId = sonarrMatch.episodeId || null; + download.arrContentType = 'episode'; } userDownloads.push(download); continue; // Skip to next torrent @@ -579,14 +573,12 @@ router.get('/user-downloads', requireAuth, async (req, res) => { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; download.arrLink = getRadarrLink(movie); - if (radarrIssues) { - download.arrQueueId = radarrMatch.id; - download.arrType = 'radarr'; - download.arrInstanceUrl = radarrMatch._instanceUrl || null; - download.arrInstanceKey = radarrMatch._instanceKey || null; - download.arrContentId = radarrMatch.movieId || null; - download.arrContentType = 'movie'; - } + download.arrQueueId = radarrMatch.id; + download.arrType = 'radarr'; + download.arrInstanceUrl = radarrMatch._instanceUrl || null; + download.arrInstanceKey = radarrMatch._instanceKey || null; + download.arrContentId = radarrMatch.movieId || null; + download.arrContentType = 'movie'; } userDownloads.push(download); continue; // Skip to next torrent @@ -925,7 +917,7 @@ router.get('/stream', requireAuth, async (req, res) => { const dlObj = { type: 'series', title: nzbName, coverArt: getCoverArt(series), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbmissing, size: slot.size, speed: slotState.speed, eta: slot.timeleft, seriesName: series.title, episodes: gatherEpisodes(nzbNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }; const issues = getImportIssues(sonarrMatch); if (issues) dlObj.importIssues = issues; - if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); if (issues) { dlObj.arrQueueId = sonarrMatch.id; dlObj.arrType = 'sonarr'; dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null; dlObj.arrInstanceKey = sonarrMatch._instanceKey || null; dlObj.arrContentId = sonarrMatch.episodeId || null; dlObj.arrContentType = 'episode'; } } + if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); dlObj.arrQueueId = sonarrMatch.id; dlObj.arrType = 'sonarr'; dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null; dlObj.arrInstanceKey = sonarrMatch._instanceKey || null; dlObj.arrContentId = sonarrMatch.episodeId || null; dlObj.arrContentType = 'episode'; } userDownloads.push(dlObj); } } @@ -944,7 +936,7 @@ router.get('/stream', requireAuth, async (req, res) => { const dlObj = { type: 'movie', title: nzbName, coverArt: getCoverArt(movie), status: slotState.status, progress: slot.percentage, mb: slot.mb, mbmissing: slot.mbmissing, size: slot.size, speed: slotState.speed, eta: slot.timeleft, movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }; const issues = getImportIssues(radarrMatch); if (issues) dlObj.importIssues = issues; - if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = getRadarrLink(movie); if (issues) { dlObj.arrQueueId = radarrMatch.id; dlObj.arrType = 'radarr'; dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null; dlObj.arrInstanceKey = radarrMatch._instanceKey || null; dlObj.arrContentId = radarrMatch.movieId || null; dlObj.arrContentType = 'movie'; } } + if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = getRadarrLink(movie); dlObj.arrQueueId = radarrMatch.id; dlObj.arrType = 'radarr'; dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null; dlObj.arrInstanceKey = radarrMatch._instanceKey || null; dlObj.arrContentId = radarrMatch.movieId || null; dlObj.arrContentType = 'movie'; } userDownloads.push(dlObj); } } @@ -1011,7 +1003,7 @@ router.get('/stream', requireAuth, async (req, res) => { const download = mapTorrentToDownload(torrent); Object.assign(download, { type: 'series', coverArt: getCoverArt(series), seriesName: series.title, episodes: gatherEpisodes(torrentNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }); const issues = getImportIssues(sonarrMatch); if (issues) download.importIssues = issues; - if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); if (issues) { download.arrQueueId = sonarrMatch.id; download.arrType = 'sonarr'; download.arrInstanceUrl = sonarrMatch._instanceUrl || null; download.arrInstanceKey = sonarrMatch._instanceKey || null; download.arrContentId = sonarrMatch.episodeId || null; download.arrContentType = 'episode'; } } + if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); download.arrQueueId = sonarrMatch.id; download.arrType = 'sonarr'; download.arrInstanceUrl = sonarrMatch._instanceUrl || null; download.arrInstanceKey = sonarrMatch._instanceKey || null; download.arrContentId = sonarrMatch.episodeId || null; download.arrContentType = 'episode'; } userDownloads.push(download); continue; } } @@ -1027,7 +1019,7 @@ router.get('/stream', requireAuth, async (req, res) => { const download = mapTorrentToDownload(torrent); Object.assign(download, { type: 'movie', coverArt: getCoverArt(movie), movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }); const issues = getImportIssues(radarrMatch); if (issues) download.importIssues = issues; - if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; download.arrLink = getRadarrLink(movie); if (issues) { download.arrQueueId = radarrMatch.id; download.arrType = 'radarr'; download.arrInstanceUrl = radarrMatch._instanceUrl || null; download.arrInstanceKey = radarrMatch._instanceKey || null; download.arrContentId = radarrMatch.movieId || null; download.arrContentType = 'movie'; } } + if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; download.arrLink = getRadarrLink(movie); download.arrQueueId = radarrMatch.id; download.arrType = 'radarr'; download.arrInstanceUrl = radarrMatch._instanceUrl || null; download.arrInstanceKey = radarrMatch._instanceKey || null; download.arrContentId = radarrMatch.movieId || null; download.arrContentType = 'movie'; } userDownloads.push(download); continue; } } From 2747ca7754bf4e0bd9be445db51f55ad7176e18b Mon Sep 17 00:00:00 2001 From: Gronod Date: Sun, 17 May 2026 23:57:06 +0100 Subject: [PATCH 2/6] feat: allow non-admin users to blocklist & search under specific conditions - Added addedOn timestamp to qBittorrent torrent mapping - Added canBlocklist helper function: true for admins, true for non-admins when (importIssues OR (torrent >1h old AND availability<100%)) - Added canBlocklist field to all download objects in /user-downloads and SSE /stream routes (8 blocks total) - Frontend button now shows when (isAdmin OR download.canBlocklist) && download.arrQueueId --- public/app.js | 2 +- server/routes/dashboard.js | 25 +++++++++++++++++++++++++ server/utils/qbittorrent.js | 1 + 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/public/app.js b/public/app.js index cf28451..0016d07 100644 --- a/public/app.js +++ b/public/app.js @@ -556,7 +556,7 @@ function createDownloadCard(download) { header.appendChild(issueBadge); } - if (isAdmin && download.arrQueueId) { + if ((isAdmin || download.canBlocklist) && download.arrQueueId) { const blBtn = document.createElement('button'); blBtn.className = 'blocklist-search-btn'; blBtn.textContent = '⛔ Blocklist & Search'; diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js index 321ae45..290b42c 100644 --- a/server/routes/dashboard.js +++ b/server/routes/dashboard.js @@ -94,6 +94,23 @@ function getRadarrLink(movie) { return `${movie._instanceUrl}/movie/${movie.titleSlug}`; } +// Determine if a download can be blocklisted by the current user +// Admins: always true (they have arrQueueId) +// Non-admins: true if importIssues OR (torrent >1h old AND availability<100%) +function canBlocklist(download, isAdmin) { + if (isAdmin) return true; + if (download.importIssues && download.importIssues.length > 0) return true; + if (download.qbittorrent && download.addedOn && download.availability) { + const oneHourAgo = Date.now() - 3600000; // 1 hour in ms + const addedOn = new Date(download.addedOn).getTime(); + const isOldEnough = addedOn < oneHourAgo; + const availability = parseFloat(download.availability); + const isLowAvailability = availability < 100; + return isOldEnough && isLowAvailability; + } + return false; +} + // Extract episode info from a Sonarr queue/history record. // Returns { season, episode, title } or null if data is missing. function extractEpisode(record) { @@ -328,6 +345,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => { dlObj.arrContentId = sonarrMatch.episodeId || null; dlObj.arrContentType = 'episode'; } + dlObj.canBlocklist = canBlocklist(dlObj, isAdmin); userDownloads.push(dlObj); } } @@ -376,6 +394,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => { dlObj.arrContentId = radarrMatch.movieId || null; dlObj.arrContentType = 'movie'; } + dlObj.canBlocklist = canBlocklist(dlObj, isAdmin); userDownloads.push(dlObj); } } @@ -539,6 +558,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => { download.arrContentId = sonarrMatch.episodeId || null; download.arrContentType = 'episode'; } + download.canBlocklist = canBlocklist(download, isAdmin); userDownloads.push(download); continue; // Skip to next torrent } @@ -580,6 +600,7 @@ router.get('/user-downloads', requireAuth, async (req, res) => { download.arrContentId = radarrMatch.movieId || null; download.arrContentType = 'movie'; } + download.canBlocklist = canBlocklist(download, isAdmin); userDownloads.push(download); continue; // Skip to next torrent } @@ -918,6 +939,7 @@ router.get('/stream', requireAuth, async (req, res) => { const issues = getImportIssues(sonarrMatch); if (issues) dlObj.importIssues = issues; if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = series.path || null; dlObj.arrLink = getSonarrLink(series); dlObj.arrQueueId = sonarrMatch.id; dlObj.arrType = 'sonarr'; dlObj.arrInstanceUrl = sonarrMatch._instanceUrl || null; dlObj.arrInstanceKey = sonarrMatch._instanceKey || null; dlObj.arrContentId = sonarrMatch.episodeId || null; dlObj.arrContentType = 'episode'; } + dlObj.canBlocklist = canBlocklist(dlObj, isAdmin); userDownloads.push(dlObj); } } @@ -937,6 +959,7 @@ router.get('/stream', requireAuth, async (req, res) => { const issues = getImportIssues(radarrMatch); if (issues) dlObj.importIssues = issues; if (isAdmin) { dlObj.downloadPath = slot.storage || null; dlObj.targetPath = movie.path || null; dlObj.arrLink = getRadarrLink(movie); dlObj.arrQueueId = radarrMatch.id; dlObj.arrType = 'radarr'; dlObj.arrInstanceUrl = radarrMatch._instanceUrl || null; dlObj.arrInstanceKey = radarrMatch._instanceKey || null; dlObj.arrContentId = radarrMatch.movieId || null; dlObj.arrContentType = 'movie'; } + dlObj.canBlocklist = canBlocklist(dlObj, isAdmin); userDownloads.push(dlObj); } } @@ -1004,6 +1027,7 @@ router.get('/stream', requireAuth, async (req, res) => { Object.assign(download, { type: 'series', coverArt: getCoverArt(series), seriesName: series.title, episodes: gatherEpisodes(torrentNameLower, sonarrQueue.data.records), allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }); const issues = getImportIssues(sonarrMatch); if (issues) download.importIssues = issues; if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = series.path || null; download.arrLink = getSonarrLink(series); download.arrQueueId = sonarrMatch.id; download.arrType = 'sonarr'; download.arrInstanceUrl = sonarrMatch._instanceUrl || null; download.arrInstanceKey = sonarrMatch._instanceKey || null; download.arrContentId = sonarrMatch.episodeId || null; download.arrContentType = 'episode'; } + download.canBlocklist = canBlocklist(download, isAdmin); userDownloads.push(download); continue; } } @@ -1020,6 +1044,7 @@ router.get('/stream', requireAuth, async (req, res) => { Object.assign(download, { type: 'movie', coverArt: getCoverArt(movie), movieName: movie.title, movieInfo: radarrMatch, allTags, matchedUserTag: matchedUserTag || null, tagBadges: showAll ? buildTagBadges(allTags, embyUserMap) : undefined }); const issues = getImportIssues(radarrMatch); if (issues) download.importIssues = issues; if (isAdmin) { download.downloadPath = download.savePath || null; download.targetPath = movie.path || null; download.arrLink = getRadarrLink(movie); download.arrQueueId = radarrMatch.id; download.arrType = 'radarr'; download.arrInstanceUrl = radarrMatch._instanceUrl || null; download.arrInstanceKey = radarrMatch._instanceKey || null; download.arrContentId = radarrMatch.movieId || null; download.arrContentType = 'movie'; } + download.canBlocklist = canBlocklist(download, isAdmin); userDownloads.push(download); continue; } } diff --git a/server/utils/qbittorrent.js b/server/utils/qbittorrent.js index 7073587..7c218ce 100644 --- a/server/utils/qbittorrent.js +++ b/server/utils/qbittorrent.js @@ -204,6 +204,7 @@ function mapTorrentToDownload(torrent) { category: torrent.category, tags: torrent.tags, savePath: torrent.content_path || torrent.save_path || null, + addedOn: torrent.added_on || null, qbittorrent: true }; } From cf7008fd5444ab2124535cd67cab8c1a8a2e4bc4 Mon Sep 17 00:00:00 2001 From: Gronod Date: Mon, 18 May 2026 00:05:31 +0100 Subject: [PATCH 3/6] docs: update documentation for blocklist & search non-admin eligibility - CHANGELOG: document button availability changes (all admin downloads, non-admin eligibility) - README: update blocklist-search endpoint description with non-admin conditions - ARCHITECTURE.md: update Authorisation Matrix, Download object table (add canBlocklist, addedOn fields), and blocklist-search API reference --- CHANGELOG.md | 3 +++ README.md | 2 +- docs/ARCHITECTURE.md | 10 +++++++--- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c59b93c..66263f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - History records are now deduplicated server-side before being sent to the client — only the most relevant record per content item per instance is returned. - Import-issue badge tooltip now uses the themed `var(--surface)` / `var(--text-primary)` / `var(--border)` CSS variables, matching the episode and toggle tooltip style. - Poller now stores `_instanceKey` on Sonarr/Radarr queue records in the cache, enabling the backend to look up API credentials for blocklist operations without an additional configuration lookup. +- **Blocklist & Search button** — now available on all admin downloads (not just those with import issues), and also available to non-admin users when: import issues are present, OR (for qBittorrent torrents only) the download is more than 1 hour old AND has less than 100% availability. +- **Download object** — added `canBlocklist` boolean field to indicate whether the current user can blocklist a given download. +- **qBittorrent torrent data** — added `addedOn` timestamp field to enable age-based blocklist eligibility checks. --- diff --git a/README.md b/README.md index 398402d..840c161 100644 --- a/README.md +++ b/README.md @@ -280,7 +280,7 @@ sofarr polls all configured services in the background and caches the results. D - `GET /api/dashboard/user-summary` — Per-user download counts (admin) - `GET /api/dashboard/status` — Server / polling / cache status (admin) - `GET /api/dashboard/cover-art` — Proxied cover art image -- `POST /api/dashboard/blocklist-search` — Blocklist a release and trigger a new search (admin) +- `POST /api/dashboard/blocklist-search` — Blocklist a release and trigger a new search (admin or non-admin with eligibility: import issues OR torrent >1h old AND availability<100%) ### History - `GET /api/history/recent` — Recently completed downloads from Sonarr/Radarr history diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ed728a2..f47c065 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -314,7 +314,7 @@ For each connected user the server: | See download/target paths | ✗ | ✓ | | See Sonarr/Radarr links | ✗ | ✓ | | View status panel | ✗ | ✓ | -| Blocklist & search (import-pending) | ✗ | ✓ | +| Blocklist & search | ✓ (when import issues OR torrent >1h old AND availability<100%) | ✓ (all downloads) | ### Tag Matching @@ -415,6 +415,7 @@ Each matched download produces an object with: | `tagBadges` | `{label, matchedUser}[]` / undefined | (Admin `showAll` only) Each tag classified against full Emby user list | | `importIssues` | string[] / null | Import warning/error messages | | `availableForUpgrade` | boolean / undefined | (History) `true` when outcome is `failed` but the content is already on disk (failed upgrade attempt) | +| `canBlocklist` | boolean | `true` if the current user can blocklist this download (admin: always; non-admin: when import issues OR torrent >1h old AND availability<100%) | | `downloadPath` | string / null | (Admin) Download client path | | `targetPath` | string / null | (Admin) *arr target path | | `arrLink` | string / null | (Admin) Link to *arr web UI | @@ -424,6 +425,7 @@ Each matched download produces an object with: | `arrInstanceKey` | string / null | (Admin, import-pending only) API key for the *arr instance | | `arrContentId` | number / null | (Admin, import-pending only) `episodeId` (Sonarr) or `movieId` (Radarr) for triggering a new search | | `arrContentType` | `'episode'`/`'movie'` / null | (Admin, import-pending only) Content type for the search command | +| `addedOn` | number / null | (qBittorrent only) Unix timestamp when the torrent was added, used for age-based blocklist eligibility | --- @@ -604,7 +606,9 @@ Admin-only per-user download counts (fetches live from APIs, not cached). ### `POST /api/dashboard/blocklist-search` -Admin-only. Removes a Sonarr/Radarr queue item with `blocklist=true` (preventing the same release being grabbed again), then immediately triggers an `EpisodeSearch` or `MoviesSearch` command. +Removes a Sonarr/Radarr queue item with `blocklist=true` (preventing the same release being grabbed again), then immediately triggers an `EpisodeSearch` or `MoviesSearch` command. + +**Access:** Admin users can blocklist any download. Non-admin users can only blocklist downloads that meet specific eligibility criteria: import issues are present, OR (for qBittorrent torrents only) the download is more than 1 hour old AND has less than 100% availability. The frontend only shows the button when the user is eligible. Requires CSRF token (`X-CSRF-Token` header). @@ -633,7 +637,7 @@ Requires CSRF token (`X-CSRF-Token` header). **Response (400):** Missing or invalid fields. -**Response (403):** Non-admin user. +**Response (403):** Non-admin user attempting to blocklist without meeting eligibility criteria (no import issues and not an eligible torrent). **Response (502):** Upstream *arr call failed. From 972b407956766c6a0945594af3b937109f970c65 Mon Sep 17 00:00:00 2001 From: Gronod Date: Mon, 18 May 2026 06:30:57 +0100 Subject: [PATCH 4/6] chore: sync package-lock.json version to 1.3.0 --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 44e94af..12af2f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sofarr", - "version": "1.1.2", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sofarr", - "version": "1.1.2", + "version": "1.3.0", "license": "MIT", "dependencies": { "axios": "^1.6.0", From e640215502ce6da2dd241a3efd4503389cdbc882 Mon Sep 17 00:00:00 2001 From: Gronod Date: Mon, 18 May 2026 06:31:31 +0100 Subject: [PATCH 5/6] chore: bump version to 1.4.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8e63422..844ded4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sofarr", - "version": "1.3.0", + "version": "1.4.0", "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": { From a0f630fb8143b09999fabb54ab4bd64064ad34f6 Mon Sep 17 00:00:00 2001 From: Gronod Date: Mon, 18 May 2026 06:35:16 +0100 Subject: [PATCH 6/6] chore: bump version to 1.3.1 (point release) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 844ded4..4f9a695 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sofarr", - "version": "1.4.0", + "version": "1.3.1", "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": {