feat: blocklist & search button for import-pending downloads with caution
- Poller now stores _instanceKey alongside _instanceUrl on Sonarr/Radarr queue records - dashboard route threads arrQueueId/arrType/arrInstanceUrl/arrInstanceKey/arrContentId/arrContentType as admin-only fields on downloads with importIssues - POST /api/dashboard/blocklist-search: admin-only, removes queue item with blocklist=true then triggers EpisodeSearch/MoviesSearch - Button renders in download card header (admin + importIssues + arrQueueId only) - Confirm dialog, loading/success/error states on the button - Kicks a background poll on success so SSE reflects removed item promptly
This commit is contained in:
@@ -457,6 +457,49 @@ function updateDownloadCard(card, download) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleBlocklistSearch(btn, download) {
|
||||||
|
if (!confirm(`Blocklist "${download.title}" and trigger a new search?\n\nThis will:\n• Remove the download from the download client\n• Add this release to the blocklist\n• Trigger an automatic search for a new release`)) return;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '⏳ Working…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/dashboard/blocklist-search', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': csrfToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
arrQueueId: download.arrQueueId,
|
||||||
|
arrType: download.arrType,
|
||||||
|
arrInstanceUrl: download.arrInstanceUrl,
|
||||||
|
arrInstanceKey: download.arrInstanceKey,
|
||||||
|
arrContentId: download.arrContentId,
|
||||||
|
arrContentType: download.arrContentType
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.textContent = '✓ Done — searching…';
|
||||||
|
btn.className = 'blocklist-search-btn success';
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Blocklist] Error:', err);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '⛔ Blocklist & Search';
|
||||||
|
btn.className = 'blocklist-search-btn error';
|
||||||
|
btn.title = `Failed: ${err.message}`;
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.className = 'blocklist-search-btn';
|
||||||
|
btn.title = 'Remove this release from the download client, add it to the blocklist, and trigger a new automatic search';
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createDownloadCard(download) {
|
function createDownloadCard(download) {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = `download-card ${download.type}`;
|
card.className = `download-card ${download.type}`;
|
||||||
@@ -511,6 +554,15 @@ function createDownloadCard(download) {
|
|||||||
issueBadge.textContent = 'Import Pending';
|
issueBadge.textContent = 'Import Pending';
|
||||||
issueBadge.setAttribute('data-tooltip', download.importIssues.join('\n'));
|
issueBadge.setAttribute('data-tooltip', download.importIssues.join('\n'));
|
||||||
header.appendChild(issueBadge);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = document.createElement('h3');
|
const title = document.createElement('h3');
|
||||||
|
|||||||
@@ -1151,6 +1151,39 @@ body {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blocklist-search-btn {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--error, #e74c3c);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--error, #e74c3c);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocklist-search-btn:hover:not(:disabled) {
|
||||||
|
background: var(--error, #e74c3c);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocklist-search-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocklist-search-btn.success {
|
||||||
|
border-color: var(--success, #27ae60);
|
||||||
|
color: var(--success, #27ae60);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blocklist-search-btn.error {
|
||||||
|
background: var(--error, #e74c3c);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
.download-user-badge {
|
.download-user-badge {
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
|||||||
@@ -321,6 +321,14 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
|||||||
dlObj.downloadPath = slot.storage || null;
|
dlObj.downloadPath = slot.storage || null;
|
||||||
dlObj.targetPath = series.path || null;
|
dlObj.targetPath = series.path || null;
|
||||||
dlObj.arrLink = getSonarrLink(series);
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
userDownloads.push(dlObj);
|
userDownloads.push(dlObj);
|
||||||
}
|
}
|
||||||
@@ -363,6 +371,14 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
|||||||
dlObj.downloadPath = slot.storage || null;
|
dlObj.downloadPath = slot.storage || null;
|
||||||
dlObj.targetPath = movie.path || null;
|
dlObj.targetPath = movie.path || null;
|
||||||
dlObj.arrLink = getRadarrLink(movie);
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
userDownloads.push(dlObj);
|
userDownloads.push(dlObj);
|
||||||
}
|
}
|
||||||
@@ -520,6 +536,14 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
|||||||
download.downloadPath = download.savePath || null;
|
download.downloadPath = download.savePath || null;
|
||||||
download.targetPath = series.path || null;
|
download.targetPath = series.path || null;
|
||||||
download.arrLink = getSonarrLink(series);
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
userDownloads.push(download);
|
userDownloads.push(download);
|
||||||
continue; // Skip to next torrent
|
continue; // Skip to next torrent
|
||||||
@@ -555,6 +579,14 @@ router.get('/user-downloads', requireAuth, async (req, res) => {
|
|||||||
download.downloadPath = download.savePath || null;
|
download.downloadPath = download.savePath || null;
|
||||||
download.targetPath = movie.path || null;
|
download.targetPath = movie.path || null;
|
||||||
download.arrLink = getRadarrLink(movie);
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
userDownloads.push(download);
|
userDownloads.push(download);
|
||||||
continue; // Skip to next torrent
|
continue; // Skip to next torrent
|
||||||
@@ -1059,4 +1091,68 @@ router.get('/stream', requireAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/dashboard/blocklist-search
|
||||||
|
*
|
||||||
|
* Admin-only. Removes a queue item from Sonarr/Radarr with blocklist=true
|
||||||
|
* (so the release is not grabbed again), then immediately triggers a new
|
||||||
|
* automatic search for the same episode/movie.
|
||||||
|
*
|
||||||
|
* Body: {
|
||||||
|
* arrQueueId: number — Sonarr/Radarr queue record id
|
||||||
|
* arrType: 'sonarr'|'radarr'
|
||||||
|
* arrInstanceUrl: string — base URL of the arr instance
|
||||||
|
* arrInstanceKey: string — API key for the arr instance
|
||||||
|
* arrContentId: number — episodeId (Sonarr) or movieId (Radarr)
|
||||||
|
* arrContentType: 'episode'|'movie'
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
router.post('/blocklist-search', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = req.user;
|
||||||
|
if (!user.isAdmin) {
|
||||||
|
return res.status(403).json({ error: 'Admin access required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { arrQueueId, arrType, arrInstanceUrl, arrInstanceKey, arrContentId, arrContentType } = req.body;
|
||||||
|
|
||||||
|
if (!arrQueueId || !arrType || !arrInstanceUrl || !arrInstanceKey || !arrContentId || !arrContentType) {
|
||||||
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
|
}
|
||||||
|
if (arrType !== 'sonarr' && arrType !== 'radarr') {
|
||||||
|
return res.status(400).json({ error: 'arrType must be sonarr or radarr' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = { 'X-Api-Key': arrInstanceKey };
|
||||||
|
|
||||||
|
// Step 1: Remove from queue with blocklist=true
|
||||||
|
await axios.delete(`${arrInstanceUrl}/api/v3/queue/${arrQueueId}`, {
|
||||||
|
headers,
|
||||||
|
params: { removeFromClient: true, blocklist: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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') {
|
||||||
|
commandBody = { name: 'MoviesSearch', movieIds: [arrContentId] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commandBody) {
|
||||||
|
await axios.post(`${arrInstanceUrl}/api/v3/command`, commandBody, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate the poll cache so the next SSE push reflects the removed item
|
||||||
|
const { pollAllServices } = require('../utils/poller');
|
||||||
|
pollAllServices().catch(() => {});
|
||||||
|
|
||||||
|
console.log(`[Dashboard] Blocklist+search: ${arrType} queueId=${arrQueueId} contentId=${arrContentId} by ${user.name}`);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Dashboard] blocklist-search error:', sanitizeError(err));
|
||||||
|
res.status(502).json({ error: 'Failed to blocklist and search', details: sanitizeError(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -160,8 +160,11 @@ async function pollAllServices() {
|
|||||||
records: sonarrQueues.flatMap(q => {
|
records: sonarrQueues.flatMap(q => {
|
||||||
const inst = sonarrInstances.find(i => i.id === q.instance);
|
const inst = sonarrInstances.find(i => i.id === q.instance);
|
||||||
const url = inst ? inst.url : null;
|
const url = inst ? inst.url : null;
|
||||||
|
const key = inst ? inst.apiKey : null;
|
||||||
return (q.data.records || []).map(r => {
|
return (q.data.records || []).map(r => {
|
||||||
if (r.series) r.series._instanceUrl = url;
|
if (r.series) r.series._instanceUrl = url;
|
||||||
|
r._instanceUrl = url;
|
||||||
|
r._instanceKey = key;
|
||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@@ -175,8 +178,11 @@ async function pollAllServices() {
|
|||||||
records: radarrQueues.flatMap(q => {
|
records: radarrQueues.flatMap(q => {
|
||||||
const inst = radarrInstances.find(i => i.id === q.instance);
|
const inst = radarrInstances.find(i => i.id === q.instance);
|
||||||
const url = inst ? inst.url : null;
|
const url = inst ? inst.url : null;
|
||||||
|
const key = inst ? inst.apiKey : null;
|
||||||
return (q.data.records || []).map(r => {
|
return (q.data.records || []).map(r => {
|
||||||
if (r.movie) r.movie._instanceUrl = url;
|
if (r.movie) r.movie._instanceUrl = url;
|
||||||
|
r._instanceUrl = url;
|
||||||
|
r._instanceKey = key;
|
||||||
return r;
|
return r;
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user